Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions frameworks/ktor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM gradle:8.12-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts gradle.properties ./
RUN gradle dependencies --no-daemon -q 2>/dev/null || true
COPY src ./src
RUN gradle buildFatJar --no-daemon -q

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/ktor-httparena.jar .
EXPOSE 8080
ENTRYPOINT ["java", \
"-server", \
"-XX:+UseG1GC", \
"-XX:+UseNUMA", \
"-XX:+AlwaysPreTouch", \
"-Dio.netty.buffer.checkBounds=false", \
"-Dio.netty.buffer.checkAccessible=false", \
"-Dio.netty.allocator.maxOrder=10", \
"-Dio.netty.leakDetection.level=disabled", \
"-Dio.netty.recycler.maxCapacityPerThread=4096", \
"-Dio.netty.eventLoopThreads=0", \
"-jar", "ktor-httparena.jar"]
37 changes: 37 additions & 0 deletions frameworks/ktor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
plugins {
kotlin("jvm") version "2.3.0"
kotlin("plugin.serialization") version "2.3.0"
id("io.ktor.plugin") version "3.4.1"
application
}

group = "com.httparena"
version = "1.0.0"

application {
mainClass.set("com.httparena.ApplicationKt")
}

repositories {
mavenCentral()
}

dependencies {
implementation("io.ktor:ktor-server-core:3.4.1")
implementation("io.ktor:ktor-server-netty:3.4.1")
implementation("io.ktor:ktor-server-compression:3.4.1")
implementation("io.ktor:ktor-server-default-headers:3.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.xerial:sqlite-jdbc:3.47.2.0")
implementation("ch.qos.logback:logback-classic:1.5.15")
}

ktor {
fatJar {
archiveFileName.set("ktor-httparena.jar")
}
}

kotlin {
jvmToolchain(21)
}
1 change: 1 addition & 0 deletions frameworks/ktor/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
kotlin.code.style=official
19 changes: 19 additions & 0 deletions frameworks/ktor/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"display_name": "ktor",
"language": "Kotlin",
"type": "framework",
"engine": "Netty",
"description": "JetBrains Ktor 3.x on Netty with Kotlin coroutines, kotlinx.serialization, JDK 21.",
"repo": "https://github.com/ktorio/ktor",
"enabled": true,
"tests": [
"baseline",
"pipelined",
"limited-conn",
"json",
"upload",
"compression",
"noisy",
"mixed"
]
}
1 change: 1 addition & 0 deletions frameworks/ktor/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "ktor-httparena"
283 changes: 283 additions & 0 deletions frameworks/ktor/src/main/kotlin/com/httparena/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package com.httparena

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayOutputStream
import java.io.File
import java.sql.Connection
import java.sql.DriverManager
import java.util.zip.GZIPOutputStream

@Serializable
data class DatasetItem(
val id: Int,
val name: String,
val category: String,
val price: Double,
val quantity: Int,
val active: Boolean,
val tags: List<String>,
val rating: RatingInfo
)

@Serializable
data class RatingInfo(
val score: Double,
val count: Int
)

@Serializable
data class ProcessedItem(
val id: Int,
val name: String,
val category: String,
val price: Double,
val quantity: Int,
val active: Boolean,
val tags: List<String>,
val rating: RatingInfo,
val total: Double
)

@Serializable
data class JsonResponse(
val items: List<ProcessedItem>,
val count: Int
)

@Serializable
data class DbItem(
val id: Int,
val name: String,
val category: String,
val price: Double,
val quantity: Int,
val active: Boolean,
val tags: List<String>,
val rating: RatingInfo
)

@Serializable
data class DbResponse(
val items: List<DbItem>,
val count: Int
)

object AppData {
val json = Json { ignoreUnknownKeys = true }
var dataset: List<DatasetItem> = emptyList()
var jsonCache: ByteArray = ByteArray(0)
var largeJsonCache: ByteArray = ByteArray(0)
var largeGzipCache: ByteArray = ByteArray(0)
val staticFiles: MutableMap<String, Pair<ByteArray, String>> = mutableMapOf()
var db: Connection? = null

private val mimeTypes = mapOf(
".css" to "text/css",
".js" to "application/javascript",
".html" to "text/html",
".woff2" to "font/woff2",
".svg" to "image/svg+xml",
".webp" to "image/webp",
".json" to "application/json"
)

fun load() {
// Dataset
val path = System.getenv("DATASET_PATH") ?: "/data/dataset.json"
val dataFile = File(path)
if (dataFile.exists()) {
dataset = json.decodeFromString<List<DatasetItem>>(dataFile.readText())
jsonCache = buildJsonCache(dataset)
}

// Large dataset for compression
val largeFile = File("/data/dataset-large.json")
if (largeFile.exists()) {
val largeItems = json.decodeFromString<List<DatasetItem>>(largeFile.readText())
largeJsonCache = buildJsonCache(largeItems)
// Pre-compress for gzip
largeGzipCache = gzipCompress(largeJsonCache)
}

// Static files
val staticDir = File("/data/static")
if (staticDir.isDirectory) {
staticDir.listFiles()?.forEach { file ->
if (file.isFile) {
val ext = file.extension.let { if (it.isNotEmpty()) ".$it" else "" }
val ct = mimeTypes[ext] ?: "application/octet-stream"
staticFiles[file.name] = file.readBytes() to ct
}
}
}

// Database
val dbFile = File("/data/benchmark.db")
if (dbFile.exists()) {
db = DriverManager.getConnection("jdbc:sqlite:file:/data/benchmark.db?mode=ro&immutable=1")
db!!.createStatement().execute("PRAGMA mmap_size=268435456")
}
}

private fun buildJsonCache(items: List<DatasetItem>): ByteArray {
val processed = items.map { d ->
ProcessedItem(
id = d.id, name = d.name, category = d.category,
price = d.price, quantity = d.quantity, active = d.active,
tags = d.tags, rating = d.rating,
total = Math.round(d.price * d.quantity * 100.0) / 100.0
)
}
val resp = JsonResponse(items = processed, count = processed.size)
return json.encodeToString(JsonResponse.serializer(), resp).toByteArray()
}

private fun gzipCompress(data: ByteArray): ByteArray {
val bos = ByteArrayOutputStream(data.size / 4)
GZIPOutputStream(bos).use { it.write(data) }
return bos.toByteArray()
}
}

fun main() {
AppData.load()
println("Ktor HttpArena server starting on :8080")

embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
install(DefaultHeaders) {
header("Server", "ktor")
}

routing {
get("/pipeline") {
call.respondText("ok", ContentType.Text.Plain)
}

get("/baseline11") {
val sum = sumQueryParams(call)
call.respondText(sum.toString(), ContentType.Text.Plain)
}

post("/baseline11") {
var sum = sumQueryParams(call)
val body = call.receiveText().trim()
body.toLongOrNull()?.let { sum += it }
call.respondText(sum.toString(), ContentType.Text.Plain)
}

get("/baseline2") {
val sum = sumQueryParams(call)
call.respondText(sum.toString(), ContentType.Text.Plain)
}

get("/json") {
if (AppData.jsonCache.isEmpty()) {
call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError)
return@get
}
call.respondBytes(AppData.jsonCache, ContentType.Application.Json)
}

get("/compression") {
if (AppData.largeJsonCache.isEmpty()) {
call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError)
return@get
}
val acceptEncoding = call.request.header(HttpHeaders.AcceptEncoding) ?: ""
if (acceptEncoding.contains("gzip") && AppData.largeGzipCache.isNotEmpty()) {
call.response.header(HttpHeaders.ContentEncoding, "gzip")
call.respondBytes(AppData.largeGzipCache, ContentType.Application.Json)
} else {
call.respondBytes(AppData.largeJsonCache, ContentType.Application.Json)
}
}

get("/db") {
val conn = AppData.db
if (conn == null) {
call.respondText("Database not available", ContentType.Text.Plain, HttpStatusCode.InternalServerError)
return@get
}
val min = call.parameters["min"]?.toDoubleOrNull() ?: 10.0
val max = call.parameters["max"]?.toDoubleOrNull() ?: 50.0

val items = mutableListOf<DbItem>()
synchronized(conn) {
val stmt = conn.prepareStatement(
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50"
)
stmt.setDouble(1, min)
stmt.setDouble(2, max)
val rs = stmt.executeQuery()
while (rs.next()) {
val tags = AppData.json.decodeFromString<List<String>>(rs.getString(7))
items.add(
DbItem(
id = rs.getInt(1),
name = rs.getString(2),
category = rs.getString(3),
price = rs.getDouble(4),
quantity = rs.getInt(5),
active = rs.getInt(6) == 1,
tags = tags,
rating = RatingInfo(score = rs.getDouble(8), count = rs.getInt(9))
)
)
}
rs.close()
stmt.close()
}
val resp = DbResponse(items = items, count = items.size)
val body = AppData.json.encodeToString(DbResponse.serializer(), resp).toByteArray()
call.respondBytes(body, ContentType.Application.Json)
}

post("/upload") {
val channel = call.receiveChannel()
var totalBytes = 0L
val buf = ByteArray(65536)
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buf)
if (read > 0) totalBytes += read
}
call.respondText(totalBytes.toString(), ContentType.Text.Plain)
}

get("/static/{filename}") {
val filename = call.parameters["filename"]
if (filename == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val entry = AppData.staticFiles[filename]
if (entry == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val (data, contentType) = entry
call.respondBytes(data, ContentType.parse(contentType))
}
}
}.start(wait = true)
}

private fun sumQueryParams(call: ApplicationCall): Long {
var sum = 0L
call.parameters.names().forEach { name ->
call.parameters[name]?.toLongOrNull()?.let { sum += it }
}
return sum
}


10 changes: 10 additions & 0 deletions frameworks/ktor/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="WARN">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
Loading