From 3ac5eda0ac3f0ab41681cbf6c3c1c95d6499e1d3 Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 3 Jun 2026 15:15:29 +0200 Subject: [PATCH 1/5] Ktor testing --- frameworks/ktor/build.gradle.kts | 3 + .../main/kotlin/com/httparena/Application.kt | 44 +-- .../src/main/kotlin/com/httparena/Utils.kt | 170 +++++------ .../kotlin/com/httparena/ApplicationTest.kt | 274 ++++++++++++++++++ 4 files changed, 389 insertions(+), 102 deletions(-) create mode 100644 frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt diff --git a/frameworks/ktor/build.gradle.kts b/frameworks/ktor/build.gradle.kts index b798d1b56..e1dfb3b08 100644 --- a/frameworks/ktor/build.gradle.kts +++ b/frameworks/ktor/build.gradle.kts @@ -27,6 +27,9 @@ dependencies { implementation(libs.logback.classic) implementation(libs.postgresql) implementation(libs.r2dbc.pool) + + testImplementation(kotlin("test")) + testImplementation(ktorLibs.server.testHost) } ktor { diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index 2510a117a..ff5b00bf6 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -26,24 +26,9 @@ import org.slf4j.LoggerFactory import java.io.File fun main() { - val appData = AppData() println("Ktor HttpArena server starting on :8080 (HTTP/1.1) and :8443 (HTTPS/HTTP+2)") - + val deps = ArenaApplicationDepsFactory.load() val environment = applicationEnvironment {} - val module: Application.() -> Unit = { - install(DefaultHeaders) { - header("Server", "ktor") - } - install(Compression) { - gzip() - } - install(ContentNegotiation) { - json(appData.json) - } - install(WebSockets) - - configureRouting(appData) - } val server = embeddedServer(Netty, environment, { enableHttp2 = true @@ -51,7 +36,7 @@ fun main() { port = 8080 host = "0.0.0.0" } - appData.keystore?.let { keyStore -> + deps.keyStore?.let { keyStore -> sslConnector( keyStore = keyStore, keyAlias = KEY_ALIAS, @@ -71,7 +56,9 @@ fun main() { host = "0.0.0.0" } } - }, module) + }) { + mainModule(deps) + } // Spin up a second server for H2C embeddedServer(Netty, environment, { @@ -94,14 +81,29 @@ fun main() { } } // Import the same endpoints for this server - module() + mainModule(deps) }.start(wait = false) server.start(wait = true) } -private fun Application.configureRouting(appData: AppData) { +internal fun Application.mainModule(appData: ArenaApplicationDeps) { + install(DefaultHeaders) { + header("Server", "ktor") + } + install(Compression) { + gzip() + } + install(ContentNegotiation) { + json(appData.json) + } + install(WebSockets) + + configureRouting(appData) +} + +private fun Application.configureRouting(appData: ArenaApplicationDeps) { fun ApplicationCall.sumQueryParams(): Long = request.queryParameters.entries().sumOf { (_, v) -> @@ -284,7 +286,7 @@ private fun Application.configureRouting(appData: AppData) { } } -fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger("crudRoutes")): Route = +fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFactory.getLogger("crudRoutes")): Route = route("/crud/items") { get { val categoryParam = call.request.queryParameters["category"] ?: "electronics" diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt index ecc87e41a..06caed656 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt @@ -5,7 +5,6 @@ import io.r2dbc.pool.ConnectionPool import io.r2dbc.pool.ConnectionPoolConfiguration import io.r2dbc.postgresql.PostgresqlConnectionConfiguration import io.r2dbc.postgresql.PostgresqlConnectionFactory -import io.r2dbc.spi.ConnectionFactoryOptions import io.r2dbc.spi.IsolationLevel import io.r2dbc.spi.ValidationDepth import kotlinx.io.Buffer @@ -74,88 +73,97 @@ const val KEY_PATH = "/certs/server.key" const val KEY_ALIAS = "server" val KEYSTORE_PASSWORD = CharArray(0) -class AppData { - private val cpuCores = Runtime.getRuntime().availableProcessors() - private val certFile = File(CERT_PATH) - private val keyFile = File(KEY_PATH) - private val datasetFile = File(System.getenv("DATASET_PATH") ?: "/data/dataset.json") - - val json = Json { ignoreUnknownKeys = true } - - /** - * Cache-aside store used by the CRUD single-item read endpoint - * (200 ms absolute TTL, in-process). - */ - val crudCache = CrudCache(ttlMillis = 200) - - /** - * Dataset from file. Used in JSON endpoints. - */ - var dataset: List = datasetFile.takeIf { it.exists() }?.let { - json.decodeFromString(it.readText()) - } ?: emptyList() - - /** - * PostgreSQL connection. Used in async database endpoints. - */ - val postgres: R2dbcDatabase? = System.getenv("DATABASE_URL")?.let { dbUrl -> - runCatching { - val uri = URI(dbUrl.replace("postgres://", "postgresql://")) - val host = uri.host - val port = if (uri.port > 0) uri.port else 5432 - val database = uri.path.removePrefix("/") - val userInfo = uri.userInfo.split(":") - - val factory = PostgresqlConnectionFactory( - PostgresqlConnectionConfiguration.builder() - .host(host) - .port(port) - .database(database) - .username(userInfo[0]) - .password(if (userInfo.size > 1) userInfo[1] else "") - .build() +object ArenaApplicationDepsFactory { + fun load(): ArenaApplicationDeps { + val cpuCores = Runtime.getRuntime().availableProcessors() + val certFile = File(CERT_PATH) + val keyFile = File(KEY_PATH) + val datasetFile = File(System.getenv("DATASET_PATH") ?: "/data/dataset.json") + val json = Json { ignoreUnknownKeys = true } + val crudCache = CrudCache(ttlMillis = 200) + val dataset: List = datasetFile.takeIf { it.exists() }?.let { + json.decodeFromString(it.readText()) + } ?: emptyList() + + val postgres: R2dbcDatabase? = System.getenv("DATABASE_URL")?.let { dbUrl -> + runCatching { + val uri = URI(dbUrl.replace("postgres://", "postgresql://")) + val host = uri.host + val port = if (uri.port > 0) uri.port else 5432 + val database = uri.path.removePrefix("/") + val userInfo = uri.userInfo.split(":") + + val factory = PostgresqlConnectionFactory( + PostgresqlConnectionConfiguration.builder() + .host(host) + .port(port) + .database(database) + .username(userInfo[0]) + .password(if (userInfo.size > 1) userInfo[1] else "") + .build() + ) + val maxConn = System.getenv("DATABASE_MAX_CONN")?.toIntOrNull() ?: (cpuCores * 2) + val pool = ConnectionPool( + ConnectionPoolConfiguration.builder(factory) + .initialSize(maxConn) + .maxSize(maxConn) + .validationQuery("") + .validationDepth(ValidationDepth.LOCAL) + .acquireRetry(0) + .build() + ) + R2dbcDatabase.connect( + connectionFactory = pool, + databaseConfig = R2dbcDatabaseConfig.Builder().apply { + explicitDialect = PostgreSQLDialect() + defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED + } + ) + } + }?.getOrNull() + + val keystore: KeyStore? = certFile.takeIf { it.exists() }?.let { certFile -> + val certs = CertificateFactory.getInstance("X.509") + .generateCertificates(certFile.inputStream()) + .map { it as X509Certificate } + .toTypedArray() + + val keyBytes = Base64.getMimeDecoder().decode( + keyFile.readText() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") ) - val maxConn = System.getenv("DATABASE_MAX_CONN")?.toIntOrNull() ?: (cpuCores * 2) - val pool = ConnectionPool( - ConnectionPoolConfiguration.builder(factory) - .initialSize(maxConn) - .maxSize(maxConn) - .validationQuery("") - .validationDepth(ValidationDepth.LOCAL) - .acquireRetry(0) - .build() - ) - R2dbcDatabase.connect( - connectionFactory = pool, - databaseConfig = R2dbcDatabaseConfig.Builder().apply { - explicitDialect = PostgreSQLDialect() - defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED - } - ) - } - }?.getOrNull() - - /** - * Keystore for TLS. Used in JSON TLS and JSON compressed endpoints. - */ - val keystore: KeyStore? = certFile.takeIf { it.exists() }?.let { certFile -> - val certs = CertificateFactory.getInstance("X.509") - .generateCertificates(certFile.inputStream()) - .map { it as X509Certificate } - .toTypedArray() - - val keyBytes = Base64.getMimeDecoder().decode( - keyFile.readText() - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\\s".toRegex(), "") - ) - val privateKey = KeyFactory.getInstance("RSA") - .generatePrivate(PKCS8EncodedKeySpec(keyBytes)) + val privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(keyBytes)) - KeyStore.getInstance("PKCS12").apply { - load(null, null) - setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD, certs) + KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD, certs) + } } + + return ArenaApplicationDeps( + json, + crudCache, + dataset, + postgres, + keystore + ) } } + +/** + * Dependencies required for the HttpArena test array. + * @property json JSON serializer. + * @property crudCache Cache-aside store for the CRUD single-item read endpoint. + * @property dataset Dataset from file. Used in JSON endpoints. + * @property keyStore Keystore for TLS. Used in JSON TLS and JSON compressed endpoints. + */ +class ArenaApplicationDeps( + val json: Json, + val crudCache: CrudCache, + val dataset: List, + val postgres: R2dbcDatabase?, + val keyStore: KeyStore?, +) diff --git a/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt new file mode 100644 index 000000000..b313b619c --- /dev/null +++ b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt @@ -0,0 +1,274 @@ +package com.httparena + +import io.ktor.client.request.* +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ApplicationTest { + + private val testJson = Json { prettyPrint = true; ignoreUnknownKeys = true } + + private val sampleDataset = listOf( + DatasetItem( + id = 1, name = "Item-1", category = "electronics", + price = 10, quantity = 2, active = true, + tags = listOf("a", "b"), rating = RatingInfo(score = 5, count = 1) + ), + DatasetItem( + id = 2, name = "Item-2", category = "books", + price = 20, quantity = 3, active = false, + tags = listOf("c"), rating = RatingInfo(score = 3, count = 2) + ), + DatasetItem( + id = 3, name = "Item-3", category = "toys", + price = 30, quantity = 1, active = true, + tags = emptyList(), rating = RatingInfo(score = 4, count = 4) + ) + ) + + private fun buildTestDeps(dataset: List = emptyList()) = ArenaApplicationDeps( + json = testJson, + crudCache = CrudCache(), + dataset = dataset, + postgres = null, + keyStore = null + ) + + private fun ApplicationTestBuilder.setup(deps: ArenaApplicationDeps = buildTestDeps()) { + application { mainModule(deps) } + } + + @Test + fun baseline11Test() = testApplication { + setup() + val sum = client.get("/baseline11?a=2&b=2").let { + assertEquals(200, it.status.value) + it.bodyAsText().toLong() + } + assertEquals(4L, sum) + } + + @Test + fun pipelineTest() = testApplication { + setup() + val response = client.get("/pipeline") + assertEquals(200, response.status.value) + assertEquals("ok", response.bodyAsText()) + } + + @Test + fun baseline11PostWithBodyTest() = testApplication { + setup() + val response = client.post("/baseline11?a=2&b=3") { + setBody("10") + } + assertEquals(200, response.status.value) + assertEquals(15L, response.bodyAsText().toLong()) + } + + @Test + fun baseline11PostWithEmptyBodyTest() = testApplication { + setup() + val response = client.post("/baseline11?a=4&b=5") + assertEquals(200, response.status.value) + assertEquals(9L, response.bodyAsText().toLong()) + } + + @Test + fun baseline2Test() = testApplication { + setup() + val response = client.get("/baseline2?x=7&y=8&z=10") + assertEquals(200, response.status.value) + assertEquals(25L, response.bodyAsText().toLong()) + } + + @Test + fun jsonProcessingTest() = testApplication { + setup(buildTestDeps(dataset = sampleDataset)) + val response = client.get("/json/2?m=3") + assertEquals(200, response.status.value) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(2, parsed.count) + assertEquals(2, parsed.items.size) + // total = price * quantity * m = 10 * 2 * 3 = 60 for the first item + assertEquals(60L, parsed.items[0].total) + assertEquals(1, parsed.items[0].id) + } + + @Test + fun jsonProcessingClampsCountTest() = testApplication { + setup(buildTestDeps(dataset = sampleDataset)) + // Requesting more than the dataset size should be clamped to dataset size + val response = client.get("/json/100") + assertEquals(200, response.status.value) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(sampleDataset.size, parsed.count) + assertEquals(sampleDataset.size, parsed.items.size) + } + + @Test + fun jsonProcessingEmptyDatasetReturnsErrorTest() = testApplication { + setup() // empty dataset + val response = client.get("/json/1") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("Dataset not loaded", response.bodyAsText()) + } + + @Test + fun uploadTest() = testApplication { + setup() + val payload = ByteArray(1024) { it.toByte() } + val response = client.post("/upload") { + setBody(payload) + } + assertEquals(200, response.status.value) + assertEquals(payload.size.toLong(), response.bodyAsText().toLong()) + } + + @Test + fun asyncDbWithoutDatabaseReturnsEmptyTest() = testApplication { + setup() + // With postgres = null, the handler catches the exception and returns an empty payload + val response = client.get("/async-db?min=0&max=100&limit=10") + assertEquals(200, response.status.value) + assertEquals("{\"items\":[],\"count\":0}", response.bodyAsText()) + } + + @Test + fun fortunesWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/fortunes") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + } + + @Test + fun crudListWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/crud/items?category=electronics&page=1&limit=10") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("list failed", response.bodyAsText()) + } + + @Test + fun crudGetByIdBadIdTest() = testApplication { + setup() + val response = client.get("/crud/items/not-a-number") + assertEquals(HttpStatusCode.BadRequest.value, response.status.value) + assertEquals("bad id", response.bodyAsText()) + } + + @Test + fun crudGetByIdCacheHitTest() = testApplication { + val deps = buildTestDeps() + val cachedItem = DbItem( + id = 42u, name = "cached", category = "electronics", + price = 5, quantity = 1, active = true, + tags = listOf("cached"), rating = RatingInfo(score = 0, count = 0) + ) + val cachedBody = testJson.encodeToString(cachedItem).toByteArray() + deps.crudCache.put(42u, cachedBody) + + setup(deps) + val response = client.get("/crud/items/42") + assertEquals(200, response.status.value) + assertEquals("HIT", response.headers["X-Cache"]) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(42u, parsed.id) + assertEquals("cached", parsed.name) + } + + @Test + fun crudGetByIdWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/crud/items/123") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("read failed", response.bodyAsText()) + } + + @Test + fun crudCreateInvalidBodyTest() = testApplication { + setup() + val response = client.post("/crud/items") { + contentType(ContentType.Application.Json) + setBody("{not-json}") + } + assertEquals(HttpStatusCode.UnprocessableEntity.value, response.status.value) + assertEquals("invalid body", response.bodyAsText()) + } + + @Test + fun crudCreateWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val body = testJson.encodeToString( + CrudCreateRequest( + id = 1u, name = "x", category = "electronics", + price = 1, quantity = 1, active = true, tags = listOf("t") + ) + ) + val response = client.post("/crud/items") { + contentType(ContentType.Application.Json) + setBody(body) + } + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("create failed", response.bodyAsText()) + } + + @Test + fun crudUpdateBadIdTest() = testApplication { + setup() + val response = client.put("/crud/items/not-a-number") { + contentType(ContentType.Application.Json) + setBody("{}") + } + assertEquals(HttpStatusCode.BadRequest.value, response.status.value) + assertEquals("bad id", response.bodyAsText()) + } + + @Test + fun crudUpdateInvalidBodyTest() = testApplication { + setup() + val response = client.put("/crud/items/1") { + contentType(ContentType.Application.Json) + setBody("{not-json}") + } + assertEquals(HttpStatusCode.UnprocessableEntity.value, response.status.value) + assertEquals("invalid body", response.bodyAsText()) + } + + @Test + fun crudUpdateWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val body = testJson.encodeToString(CrudUpdateRequest(name = "new-name", price = 99, quantity = 5)) + val response = client.put("/crud/items/1") { + contentType(ContentType.Application.Json) + setBody(body) + } + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("update failed", response.bodyAsText()) + } + + @Test + fun unknownRouteReturns404Test() = testApplication { + setup() + val response = client.get("/does-not-exist") + assertEquals(HttpStatusCode.NotFound.value, response.status.value) + } + + @Test + fun serverHeaderTest() = testApplication { + setup() + val response = client.get("/pipeline") + assertEquals("ktor", response.headers["Server"]) + assertNotNull(response.headers["Date"]) + assertTrue(response.status.value in 200..299) + } +} From 6fd857f6022ec7a585d9cd8c048261fefaa85f1e Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 3 Jun 2026 15:55:57 +0200 Subject: [PATCH 2/5] Small response tweaks --- .../main/kotlin/com/httparena/Application.kt | 25 ++++++++----------- .../ktor/src/test/resources/baseline11.http | 1 + .../src/test/resources/baseline11post.http | 3 +++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 frameworks/ktor/src/test/resources/baseline11.http create mode 100644 frameworks/ktor/src/test/resources/baseline11post.http diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index ff5b00bf6..d31406fd9 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -2,6 +2,8 @@ package com.httparena import com.httparena.DbResponse.Companion.toResponse import io.ktor.http.* +import io.ktor.http.content.ByteArrayContent +import io.ktor.http.content.TextContent import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -104,19 +106,23 @@ internal fun Application.mainModule(appData: ArenaApplicationDeps) { } private fun Application.configureRouting(appData: ArenaApplicationDeps) { + val pipelineResponse = ByteArrayContent("ok".toByteArray(), ContentType.Text.Plain) fun ApplicationCall.sumQueryParams(): Long = request.queryParameters.entries().sumOf { (_, v) -> v.sumOf { it.toLongOrNull() ?: 0L } } + suspend fun ApplicationCall.respondNumber(long: Long) = + respond(TextContent(long.toString(), ContentType.Text.Plain)) + routing { /** * Pipelined * https://www.http-arena.com/docs/test-profiles/h1/isolated/pipelined/ */ get("/pipeline") { - call.respondText("ok", ContentType.Text.Plain) + call.respond(pipelineResponse) } /** @@ -124,10 +130,7 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ */ get("/baseline11") { - call.respondText( - call.sumQueryParams().toString(), - ContentType.Text.Plain - ) + call.respondNumber(call.sumQueryParams()) } /** @@ -140,10 +143,7 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { call.respondText(sum.toString(), ContentType.Text.Plain) return@post } - call.respondText( - (sum + body).toString(), - ContentType.Text.Plain - ) + call.respondNumber(sum + body) } /** @@ -151,10 +151,7 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ */ get("/baseline2") { - call.respondText( - call.sumQueryParams().toString(), - ContentType.Text.Plain - ) + call.respondNumber(call.sumQueryParams()) } /** @@ -204,7 +201,7 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { call.respond(items.toResponse()) } catch (e: Exception) { log.error("Failed to load items from DB", e) - call.respondBytes("{\"items\":[],\"count\":0}".toByteArray(), ContentType.Application.Json) + call.respondText("{\"items\":[],\"count\":0}", ContentType.Application.Json) } } diff --git a/frameworks/ktor/src/test/resources/baseline11.http b/frameworks/ktor/src/test/resources/baseline11.http new file mode 100644 index 000000000..cb46b88c3 --- /dev/null +++ b/frameworks/ktor/src/test/resources/baseline11.http @@ -0,0 +1 @@ +GET http://127.0.0.1:8080/baseline11?a=124389&b=398&c=123328 diff --git a/frameworks/ktor/src/test/resources/baseline11post.http b/frameworks/ktor/src/test/resources/baseline11post.http new file mode 100644 index 000000000..e4bbaa977 --- /dev/null +++ b/frameworks/ktor/src/test/resources/baseline11post.http @@ -0,0 +1,3 @@ +POST http://127.0.0.1:8080/baseline11?a=124389&b=398&c=123328 + +874211 From 1830fea5cf51cc5ef3fb03a9663babfc71e54c9a Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 3 Jun 2026 16:11:01 +0200 Subject: [PATCH 3/5] Use JDBC for connections --- frameworks/ktor/build.gradle.kts | 4 +- frameworks/ktor/gradle/libs.versions.toml | 6 +- .../main/kotlin/com/httparena/Application.kt | 121 ++++++++++-------- .../src/main/kotlin/com/httparena/Utils.kt | 61 ++++----- 4 files changed, 97 insertions(+), 95 deletions(-) diff --git a/frameworks/ktor/build.gradle.kts b/frameworks/ktor/build.gradle.kts index e1dfb3b08..9af2f98a0 100644 --- a/frameworks/ktor/build.gradle.kts +++ b/frameworks/ktor/build.gradle.kts @@ -22,11 +22,11 @@ dependencies { implementation(ktorLibs.server.htmlBuilder) implementation(libs.exposed.core) - implementation(libs.exposed.r2dbc) + implementation(libs.exposed.jdbc) implementation(libs.exposed.json) implementation(libs.logback.classic) implementation(libs.postgresql) - implementation(libs.r2dbc.pool) + implementation(libs.hikaricp) testImplementation(kotlin("test")) testImplementation(ktorLibs.server.testHost) diff --git a/frameworks/ktor/gradle/libs.versions.toml b/frameworks/ktor/gradle/libs.versions.toml index 1e5b12f24..8b41f64f8 100644 --- a/frameworks/ktor/gradle/libs.versions.toml +++ b/frameworks/ktor/gradle/libs.versions.toml @@ -7,10 +7,10 @@ logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.15 # Database exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } -exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-json = { module = "org.jetbrains.exposed:exposed-json", version.ref = "exposed" } -postgresql = { module = "org.postgresql:r2dbc-postgresql", version = "1.1.1.RELEASE" } -r2dbc-pool = { module = "io.r2dbc:r2dbc-pool", version = "1.0.2.RELEASE" } +postgresql = { module = "org.postgresql:postgresql", version = "42.7.4" } +hikaricp = { module = "com.zaxxer:HikariCP", version = "6.2.1" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index d31406fd9..56bd3c955 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -18,11 +18,12 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.utils.io.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.html.* import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction -import org.jetbrains.exposed.v1.r2dbc.* +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File @@ -189,13 +190,14 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { val max = call.request.queryParameters["max"]?.toIntOrNull() ?: 50 val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 50).coerceIn(1, 50) try { - val items = suspendTransaction(appData.postgres, readOnly = true) { - with(ItemTable) { - selectAll() - .where { price.between(min, max) } - .limit(limit) - .map(::toDbItem) - .toList() + val items = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + with(ItemTable) { + selectAll() + .where { price.between(min, max) } + .limit(limit) + .map(::toDbItem) + } } } call.respond(items.toResponse()) @@ -248,10 +250,12 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { get("/fortunes") { val fortunes = mutableListOf() try { - suspendTransaction(appData.postgres, readOnly = true) { - FortuneTable.selectAll() - .map(FortuneTable::toFortune) - .toList(fortunes) + withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + FortuneTable.selectAll() + .map(FortuneTable::toFortune) + .toCollection(fortunes) + } } } catch (e: Exception) { log.error("Failed to load fortunes from DB", e) @@ -292,13 +296,14 @@ fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFacto val offset = (page - 1).toLong() * limit try { - val items = suspendTransaction(appData.postgres, readOnly = true) { - ItemTable.selectAll() - .where { ItemTable.category eq categoryParam } - .orderBy(ItemTable.id, SortOrder.ASC) - .limit(limit).offset(offset) - .map(ItemTable::toDbItem) - .toList() + val items = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + ItemTable.selectAll() + .where { ItemTable.category eq categoryParam } + .orderBy(ItemTable.id, SortOrder.ASC) + .limit(limit).offset(offset) + .map(ItemTable::toDbItem) + } } call.respond(CrudListResponse(items = items, total = items.size, page = page, limit = limit)) } catch (e: Exception) { @@ -321,12 +326,14 @@ fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFacto } try { - val row = suspendTransaction(appData.postgres, readOnly = true) { - ItemTable.selectAll() - .where { ItemTable.id eq id } - .limit(1) - .map(ItemTable::toDbItem) - .firstOrNull() + val row = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + ItemTable.selectAll() + .where { ItemTable.id eq id } + .limit(1) + .map(ItemTable::toDbItem) + .firstOrNull() + } } if (row == null) { call.respondText("not found", status = HttpStatusCode.NotFound) @@ -350,20 +357,22 @@ fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFacto return@post } try { - suspendTransaction(appData.postgres) { - ItemTable.upsert( - keys = arrayOf(ItemTable.id), - onUpdateExclude = listOf(ItemTable.ratingScore, ItemTable.ratingCount), - ) { - it[id] = req.id - it[name] = req.name - it[category] = req.category - it[price] = req.price - it[quantity] = req.quantity - it[active] = req.active - it[tags] = req.tags - it[ratingScore] = 0 - it[ratingCount] = 0 + withContext(Dispatchers.IO) { + transaction(appData.postgres) { + ItemTable.upsert( + keys = arrayOf(ItemTable.id), + onUpdateExclude = listOf(ItemTable.ratingScore, ItemTable.ratingCount), + ) { + it[id] = req.id + it[name] = req.name + it[category] = req.category + it[price] = req.price + it[quantity] = req.quantity + it[active] = req.active + it[tags] = req.tags + it[ratingScore] = 0 + it[ratingCount] = 0 + } } } appData.crudCache.invalidate(req.id) @@ -391,20 +400,22 @@ fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFacto return@put } try { - val updated = suspendTransaction(appData.postgres, readOnly = false) { - val rows = ItemTable.update({ ItemTable.id eq id }) { stmt -> - req.name?.let { v -> stmt[ItemTable.name] = v } - req.price?.let { v -> stmt[ItemTable.price] = v } - req.quantity?.let { v -> stmt[ItemTable.quantity] = v } - } - if (rows == 0) { - null - } else { - ItemTable.selectAll() - .where { ItemTable.id eq id } - .limit(1) - .map(ItemTable::toDbItem) - .firstOrNull() + val updated = withContext(Dispatchers.IO) { + transaction(appData.postgres) { + val rows = ItemTable.update({ ItemTable.id eq id }) { stmt -> + req.name?.let { v -> stmt[ItemTable.name] = v } + req.price?.let { v -> stmt[ItemTable.price] = v } + req.quantity?.let { v -> stmt[ItemTable.quantity] = v } + } + if (rows == 0) { + null + } else { + ItemTable.selectAll() + .where { ItemTable.id eq id } + .limit(1) + .map(ItemTable::toDbItem) + .firstOrNull() + } } } appData.crudCache.invalidate(id) diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt index 06caed656..e7e400779 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt @@ -1,18 +1,13 @@ package com.httparena +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource import io.ktor.utils.io.core.discard -import io.r2dbc.pool.ConnectionPool -import io.r2dbc.pool.ConnectionPoolConfiguration -import io.r2dbc.postgresql.PostgresqlConnectionConfiguration -import io.r2dbc.postgresql.PostgresqlConnectionFactory -import io.r2dbc.spi.IsolationLevel -import io.r2dbc.spi.ValidationDepth import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.serialization.json.Json -import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect -import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase -import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database import java.io.File import java.net.URI import java.security.KeyFactory @@ -20,6 +15,7 @@ import java.security.KeyStore import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.security.spec.PKCS8EncodedKeySpec +import java.sql.Connection import java.util.Base64 import java.util.concurrent.ConcurrentHashMap @@ -85,38 +81,33 @@ object ArenaApplicationDepsFactory { json.decodeFromString(it.readText()) } ?: emptyList() - val postgres: R2dbcDatabase? = System.getenv("DATABASE_URL")?.let { dbUrl -> + val postgres: Database? = System.getenv("DATABASE_URL")?.let { dbUrl -> runCatching { val uri = URI(dbUrl.replace("postgres://", "postgresql://")) val host = uri.host val port = if (uri.port > 0) uri.port else 5432 val database = uri.path.removePrefix("/") val userInfo = uri.userInfo.split(":") - - val factory = PostgresqlConnectionFactory( - PostgresqlConnectionConfiguration.builder() - .host(host) - .port(port) - .database(database) - .username(userInfo[0]) - .password(if (userInfo.size > 1) userInfo[1] else "") - .build() - ) + val user = userInfo[0] + val password = if (userInfo.size > 1) userInfo[1] else "" val maxConn = System.getenv("DATABASE_MAX_CONN")?.toIntOrNull() ?: (cpuCores * 2) - val pool = ConnectionPool( - ConnectionPoolConfiguration.builder(factory) - .initialSize(maxConn) - .maxSize(maxConn) - .validationQuery("") - .validationDepth(ValidationDepth.LOCAL) - .acquireRetry(0) - .build() - ) - R2dbcDatabase.connect( - connectionFactory = pool, - databaseConfig = R2dbcDatabaseConfig.Builder().apply { - explicitDialect = PostgreSQLDialect() - defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED + + val hikariConfig = HikariConfig().apply { + jdbcUrl = "jdbc:postgresql://$host:$port/$database" + driverClassName = "org.postgresql.Driver" + username = user + this.password = password + maximumPoolSize = maxConn + minimumIdle = maxConn + isAutoCommit = false + transactionIsolation = "TRANSACTION_READ_COMMITTED" + validate() + } + val dataSource = HikariDataSource(hikariConfig) + Database.connect( + datasource = dataSource, + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_READ_COMMITTED } ) } @@ -164,6 +155,6 @@ class ArenaApplicationDeps( val json: Json, val crudCache: CrudCache, val dataset: List, - val postgres: R2dbcDatabase?, + val postgres: Database?, val keyStore: KeyStore?, ) From 9d6fa78116a07a4b3fb21beb1e23b1c9d2557147 Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 3 Jun 2026 18:51:40 +0200 Subject: [PATCH 4/5] Tweaks for Ktor's Netty and query param summation --- frameworks/ktor/Dockerfile | 1 + frameworks/ktor/build.gradle.kts | 1 + frameworks/ktor/gradle/libs.versions.toml | 1 + .../main/kotlin/com/httparena/Application.kt | 142 ++++++++++++------ .../kotlin/com/httparena/ApplicationTest.kt | 19 +-- 5 files changed, 99 insertions(+), 65 deletions(-) diff --git a/frameworks/ktor/Dockerfile b/frameworks/ktor/Dockerfile index 2313c3a7b..cdceee311 100644 --- a/frameworks/ktor/Dockerfile +++ b/frameworks/ktor/Dockerfile @@ -16,6 +16,7 @@ ENTRYPOINT ["java", \ "-XX:+UseNUMA", \ "-XX:+AlwaysPreTouch", \ "-XX:-OmitStackTraceInFastThrow", \ + "-Dio.netty.transport.noNative=false", \ "-Dio.netty.buffer.checkBounds=false", \ "-Dio.netty.buffer.checkAccessible=false", \ "-Dio.netty.allocator.maxOrder=10", \ diff --git a/frameworks/ktor/build.gradle.kts b/frameworks/ktor/build.gradle.kts index 9af2f98a0..7ce14313e 100644 --- a/frameworks/ktor/build.gradle.kts +++ b/frameworks/ktor/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(libs.logback.classic) implementation(libs.postgresql) implementation(libs.hikaricp) + runtimeOnly(libs.netty.native.epoll) testImplementation(kotlin("test")) testImplementation(ktorLibs.server.testHost) diff --git a/frameworks/ktor/gradle/libs.versions.toml b/frameworks/ktor/gradle/libs.versions.toml index 8b41f64f8..7d1c776b0 100644 --- a/frameworks/ktor/gradle/libs.versions.toml +++ b/frameworks/ktor/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.3.21" [libraries] logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.15" } +netty-native-epoll = { module = "io.netty:netty-transport-native-epoll", version = "4.2.14.Final" } # Database exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index 56bd3c955..6ea3b21f6 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -2,8 +2,7 @@ package com.httparena import com.httparena.DbResponse.Companion.toResponse import io.ktor.http.* -import io.ktor.http.content.ByteArrayContent -import io.ktor.http.content.TextContent +import io.ktor.http.content.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -12,18 +11,24 @@ import io.ktor.server.http.content.* import io.ktor.server.netty.* import io.ktor.server.plugins.compression.* import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.defaultheaders.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.utils.io.* +import io.netty.channel.ChannelOption +import io.netty.channel.WriteBufferWaterMark +import io.netty.handler.flush.FlushConsolidationHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.html.* -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.between +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.upsert import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File @@ -32,8 +37,27 @@ fun main() { println("Ktor HttpArena server starting on :8080 (HTTP/1.1) and :8443 (HTTPS/HTTP+2)") val deps = ArenaApplicationDepsFactory.load() val environment = applicationEnvironment {} + val tweakNettyParameters: NettyApplicationEngine.Configuration.() -> Unit = { + shareWorkGroup = true + runningLimit = 64 + + configureBootstrap = { + childOption(ChannelOption.TCP_NODELAY, true) + childOption(ChannelOption.SO_REUSEADDR, true) + childOption( + ChannelOption.WRITE_BUFFER_WATER_MARK, + WriteBufferWaterMark(64 * 1024, 256 * 1024) + ) + } + + channelPipelineConfig = { + addFirst("flush-consolidation", FlushConsolidationHandler(16, true)) + } + } + val server = embeddedServer(Netty, environment, { enableHttp2 = true + tweakNettyParameters() connector { port = 8080 @@ -66,6 +90,7 @@ fun main() { // Spin up a second server for H2C embeddedServer(Netty, environment, { enableH2c = true + tweakNettyParameters() connector { port = 8082 @@ -92,15 +117,6 @@ fun main() { } internal fun Application.mainModule(appData: ArenaApplicationDeps) { - install(DefaultHeaders) { - header("Server", "ktor") - } - install(Compression) { - gzip() - } - install(ContentNegotiation) { - json(appData.json) - } install(WebSockets) configureRouting(appData) @@ -109,10 +125,22 @@ internal fun Application.mainModule(appData: ArenaApplicationDeps) { private fun Application.configureRouting(appData: ArenaApplicationDeps) { val pipelineResponse = ByteArrayContent("ok".toByteArray(), ContentType.Text.Plain) - fun ApplicationCall.sumQueryParams(): Long = - request.queryParameters.entries().sumOf { (_, v) -> - v.sumOf { it.toLongOrNull() ?: 0L } + fun ApplicationCall.sumQueryParams(): Long { + var start = 0 + var sum = 0L + for (i in request.uri.indices) { + when(request.uri[i]) { + '=' -> { + start = i + 1 + } + '&' -> { + val v = request.uri.substring(start, i) + sum += v.toLongOrNull() ?: 0 + } + } } + return sum + (request.uri.substring(start).toLongOrNull() ?: 0) + } suspend fun ApplicationCall.respondNumber(long: Long) = respond(TextContent(long.toString(), ContentType.Text.Plain)) @@ -161,49 +189,62 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/json-tls/ * https://www.http-arena.com/docs/test-profiles/h1/isolated/json-compressed/ */ - get("/json/{count}") { - if (appData.dataset.isEmpty()) { - call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError) - return@get + route("/json/{count}") { + install(Compression) { + gzip() } - var count = call.pathParameters["count"]?.toIntOrNull() ?: 0 - if (count < 0) count = 0 - if (count > appData.dataset.size) count = appData.dataset.size - val m = call.request.queryParameters["m"]?.toIntOrNull() ?: 1 - val processed = appData.dataset.take(count).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 = d.price.toLong() * d.quantity * m - ) + install(ContentNegotiation) { + json(appData.json) + } + get { + if (appData.dataset.isEmpty()) { + call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError) + return@get + } + var count = call.pathParameters["count"]?.toIntOrNull() ?: 0 + if (count < 0) count = 0 + if (count > appData.dataset.size) count = appData.dataset.size + val m = call.request.queryParameters["m"]?.toIntOrNull() ?: 1 + val processed = appData.dataset.take(count).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 = d.price.toLong() * d.quantity * m + ) + } + call.respond(JsonResponse(items = processed, count = count)) } - call.respond(JsonResponse(items = processed, count = count)) } /** * Async DB * https://www.http-arena.com/docs/test-profiles/h1/isolated/async-database/ */ - get("/async-db") { - val min = call.request.queryParameters["min"]?.toIntOrNull() ?: 10 - val max = call.request.queryParameters["max"]?.toIntOrNull() ?: 50 - val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 50).coerceIn(1, 50) - try { - val items = withContext(Dispatchers.IO) { - transaction(appData.postgres, readOnly = true) { - with(ItemTable) { - selectAll() - .where { price.between(min, max) } - .limit(limit) - .map(::toDbItem) + route("/async-db") { + install(ContentNegotiation) { + json(appData.json) + } + get { + val min = call.request.queryParameters["min"]?.toIntOrNull() ?: 10 + val max = call.request.queryParameters["max"]?.toIntOrNull() ?: 50 + val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 50).coerceIn(1, 50) + try { + val items = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + with(ItemTable) { + selectAll() + .where { price.between(min, max) } + .limit(limit) + .map(::toDbItem) + } } } + call.respond(items.toResponse()) + } catch (e: Exception) { + log.error("Failed to load items from DB", e) + call.respondText("{\"items\":[],\"count\":0}", ContentType.Application.Json) } - call.respond(items.toResponse()) - } catch (e: Exception) { - log.error("Failed to load items from DB", e) - call.respondText("{\"items\":[],\"count\":0}", ContentType.Application.Json) } } @@ -289,6 +330,9 @@ private fun Application.configureRouting(appData: ArenaApplicationDeps) { fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFactory.getLogger("crudRoutes")): Route = route("/crud/items") { + install(ContentNegotiation) { + json(appData.json) + } get { val categoryParam = call.request.queryParameters["category"] ?: "electronics" val page = (call.request.queryParameters["page"]?.toIntOrNull() ?: 1).coerceAtLeast(1) diff --git a/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt index b313b619c..5c21b80e2 100644 --- a/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt +++ b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt @@ -1,17 +1,12 @@ package com.httparena import io.ktor.client.request.* -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue class ApplicationTest { @@ -263,12 +258,4 @@ class ApplicationTest { assertEquals(HttpStatusCode.NotFound.value, response.status.value) } - @Test - fun serverHeaderTest() = testApplication { - setup() - val response = client.get("/pipeline") - assertEquals("ktor", response.headers["Server"]) - assertNotNull(response.headers["Date"]) - assertTrue(response.status.value in 200..299) - } } From 191d04424ad18ae713f2592c6dbe23213640cb4d Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 3 Jun 2026 22:22:28 +0200 Subject: [PATCH 5/5] Drop tuning parameters on Ktor Netty engine --- .../main/kotlin/com/httparena/Application.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index 6ea3b21f6..ad01df537 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -37,27 +37,9 @@ fun main() { println("Ktor HttpArena server starting on :8080 (HTTP/1.1) and :8443 (HTTPS/HTTP+2)") val deps = ArenaApplicationDepsFactory.load() val environment = applicationEnvironment {} - val tweakNettyParameters: NettyApplicationEngine.Configuration.() -> Unit = { - shareWorkGroup = true - runningLimit = 64 - - configureBootstrap = { - childOption(ChannelOption.TCP_NODELAY, true) - childOption(ChannelOption.SO_REUSEADDR, true) - childOption( - ChannelOption.WRITE_BUFFER_WATER_MARK, - WriteBufferWaterMark(64 * 1024, 256 * 1024) - ) - } - - channelPipelineConfig = { - addFirst("flush-consolidation", FlushConsolidationHandler(16, true)) - } - } val server = embeddedServer(Netty, environment, { enableHttp2 = true - tweakNettyParameters() connector { port = 8080 @@ -90,7 +72,6 @@ fun main() { // Spin up a second server for H2C embeddedServer(Netty, environment, { enableH2c = true - tweakNettyParameters() connector { port = 8082