diff --git a/frameworks/ktor/Dockerfile b/frameworks/ktor/Dockerfile new file mode 100644 index 0000000..eaa1f62 --- /dev/null +++ b/frameworks/ktor/Dockerfile @@ -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"] diff --git a/frameworks/ktor/build.gradle.kts b/frameworks/ktor/build.gradle.kts new file mode 100644 index 0000000..8660ea7 --- /dev/null +++ b/frameworks/ktor/build.gradle.kts @@ -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) +} diff --git a/frameworks/ktor/gradle.properties b/frameworks/ktor/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/frameworks/ktor/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/frameworks/ktor/meta.json b/frameworks/ktor/meta.json new file mode 100644 index 0000000..71d7e7a --- /dev/null +++ b/frameworks/ktor/meta.json @@ -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" + ] +} diff --git a/frameworks/ktor/settings.gradle.kts b/frameworks/ktor/settings.gradle.kts new file mode 100644 index 0000000..0070044 --- /dev/null +++ b/frameworks/ktor/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ktor-httparena" diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt new file mode 100644 index 0000000..260d8e4 --- /dev/null +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -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, + 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, + val rating: RatingInfo, + val total: Double +) + +@Serializable +data class JsonResponse( + val items: List, + 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, + val rating: RatingInfo +) + +@Serializable +data class DbResponse( + val items: List, + val count: Int +) + +object AppData { + val json = Json { ignoreUnknownKeys = true } + var dataset: List = emptyList() + var jsonCache: ByteArray = ByteArray(0) + var largeJsonCache: ByteArray = ByteArray(0) + var largeGzipCache: ByteArray = ByteArray(0) + val staticFiles: MutableMap> = 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>(dataFile.readText()) + jsonCache = buildJsonCache(dataset) + } + + // Large dataset for compression + val largeFile = File("/data/dataset-large.json") + if (largeFile.exists()) { + val largeItems = json.decodeFromString>(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): 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() + 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>(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 +} + + diff --git a/frameworks/ktor/src/main/resources/logback.xml b/frameworks/ktor/src/main/resources/logback.xml new file mode 100644 index 0000000..c6e9b33 --- /dev/null +++ b/frameworks/ktor/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + +