diff --git a/frameworks/neton/README.md b/frameworks/neton/README.md index e8571755a..a43e265a4 100644 --- a/frameworks/neton/README.md +++ b/frameworks/neton/README.md @@ -6,7 +6,7 @@ Kotlin Multiplatform compiled to a native executable, on the hyper4k engine The framework is consumed from Maven Central as a single coordinate: ```kotlin -implementation("com.netonstream:neton:1.0.0-beta7") +implementation("com.netonstream:neton:1.0.0-beta13") ``` That one dependency carries core, logging, HTTP, routing and the hyper4k engine, diff --git a/frameworks/neton/build.gradle.kts b/frameworks/neton/build.gradle.kts index 478855232..cf4d4a1a4 100644 --- a/frameworks/neton/build.gradle.kts +++ b/frameworks/neton/build.gradle.kts @@ -10,13 +10,19 @@ repositories { // One published coordinate. `neton` brings core + logging + http + routing and // the hyper4k engine, so the arena entry builds from Maven exactly like any // other application would — no source checkout, no composite build. -val netonVersion = "1.0.0-beta7" +val netonVersion = "1.0.0-beta15" kotlin { // The arena builds linuxX64; macosArm64 is here so the endpoints can be // exercised on a developer machine. listOf(macosArm64(), linuxX64(), linuxArm64()).forEach { target -> - target.binaries.executable { entryPoint = "main" } + target.binaries.executable { + entryPoint = "main" + // hyper4k and sqlx4k (neton-database) are each a Rust static library, so + // both bundle the Rust runtime — linking both defines rust_eh_personality + // (and friends) twice. The copies are identical; take the first. + linkerOpts("--allow-multiple-definition") + } } sourceSets { @@ -27,6 +33,8 @@ kotlin { dependsOn(commonMain.get()) dependencies { implementation("com.netonstream:neton:$netonVersion") + // async-db / fortunes: async Postgres via sqlx4k. + implementation("com.netonstream:neton-database:$netonVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") // Encoding straight into a byte buffer, rather than to a String the diff --git a/frameworks/neton/config/application.conf b/frameworks/neton/config/application.conf index 792564eae..daaa6aad3 100644 --- a/frameworks/neton/config/application.conf +++ b/frameworks/neton/config/application.conf @@ -37,8 +37,9 @@ timeout = 0 # 524,288 clears the peak with room to spare. The counter itself costs nothing; # the memory belongs to the requests actually in flight, and the box has 251 GiB. maxConnections = 524288 -# No response compression is implemented, so nothing claims to do it. -enableCompression = false +# Dynamic gzip response compression (beta15+): compressible responses are gzipped +# when the client sends Accept-Encoding. Required for json-comp. +enableCompression = true [logging] # Measured runs must not spend request time on log lines. diff --git a/frameworks/neton/config/database.conf b/frameworks/neton/config/database.conf new file mode 100644 index 000000000..9105508ae --- /dev/null +++ b/frameworks/neton/config/database.conf @@ -0,0 +1,7 @@ +# Postgres sidecar the arena starts for the DB profiles (async-db / fortunes). +# Host networking, so the framework reaches it on localhost:5432. Fixed +# credentials/db name are set by the harness (validate.sh / benchmark.sh). +[default] +driver = "POSTGRESQL" +uri = "postgresql://bench:bench@localhost:5432/benchmark" +debug = false diff --git a/frameworks/neton/meta.json b/frameworks/neton/meta.json index 1434e2cbd..8419938f8 100644 --- a/frameworks/neton/meta.json +++ b/frameworks/neton/meta.json @@ -10,7 +10,7 @@ "request": true, "response": true }, - "description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080 and HTTP/2 cleartext on 8082 from one route table.", + "description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080, HTTP/2 cleartext on 8082, HTTP/1.1+TLS on 8081 and HTTP/2+TLS on 8443 (ALPN) from one route table; static files and TLS terminate in the framework.", "repo": "https://github.com/netonframework/neton", "enabled": true, "tests": [ @@ -20,8 +20,17 @@ "async", "latency-1m", "latency-10k", + "latency-500k-8cpu", "baseline-h2c", - "json-h2c" + "json-h2c", + "json-comp", + "json-tls", + "8gbit", + "static-tls", + "baseline-h2", + "static-h2", + "async-db", + "fortunes" ], "maintainers": [ "zoujiaqing" diff --git a/frameworks/neton/src/nativeMain/kotlin/Main.kt b/frameworks/neton/src/nativeMain/kotlin/Main.kt index 621a94913..ec282c2b3 100644 --- a/frameworks/neton/src/nativeMain/kotlin/Main.kt +++ b/frameworks/neton/src/nativeMain/kotlin/Main.kt @@ -1,5 +1,7 @@ import kotlin.native.runtime.GC import kotlin.native.runtime.NativeRuntimeApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -24,8 +26,12 @@ import neton.core.config.readConfigFile import neton.core.http.HttpContext import neton.core.http.HttpStatus import neton.core.http.adapter.HttpServerConfig +import neton.database.database +import neton.database.dbContext import neton.http.http import neton.http.hyper4k.Hyper4kHttpAdapter +import neton.http.static.staticFiles +import neton.core.http.adapter.TlsSettings import neton.routing.* /** @@ -42,8 +48,10 @@ import neton.routing.* * same socket by prior knowledge, so the second listener is a second adapter * over the same frozen context, not a second application. * - * Not subscribed (see meta.json): the TLS profiles — the engine terminates no - * TLS today — plus json-comp, which needs gzip/br response compression. + * Not subscribed (see meta.json): the profiles that need capabilities the engine + * does not have yet — HTTP/3, gRPC, WebSocket — and the multi-service DB profiles + * (async-db, fortunes, production-stack). json-comp is served here: the framework + * gzip-compresses compressible responses when the client sends Accept-Encoding. */ /** @@ -57,6 +65,16 @@ import neton.routing.* */ private const val H1_PORT = 8080 private val H2C_PORT = getEnv("ARENA_H2C_PORT")?.toIntOrNull() ?: 8082 +// TLS listeners. 8081 serves the HTTP/1.1 + TLS profiles (json-tls, static-tls, +// 8gbit, tls); 8443 serves the HTTP/2 + TLS ones (baseline-h2, static-h2) via +// ALPN. Certificates are mounted read-only at /certs by the harness. Overridable +// so a dev machine need not hold the harness ports or certs. +private val H1TLS_PORT = getEnv("ARENA_H1TLS_PORT")?.toIntOrNull() ?: 8081 +private val H2TLS_PORT = getEnv("ARENA_H2TLS_PORT")?.toIntOrNull() ?: 8443 +private val CERT_PATH = getEnv("ARENA_CERT") ?: "/certs/server.crt" +private val KEY_PATH = getEnv("ARENA_KEY") ?: "/certs/server.key" +private val STATIC_DIR = getEnv("ARENA_STATIC") ?: "/data/static" +private val TLS_ENABLED = readConfigFile(CERT_PATH) != null && readConfigFile(KEY_PATH) != null /** * Mounted read-only by the harness: -v data/dataset.json:/data/dataset.json:ro. @@ -103,6 +121,13 @@ fun main(args: Array) { port = H1_PORT } + // async-db / fortunes need Postgres. Gated so the baseline A/B can run the + // exact same binary with the DB out of the picture (ARENA_DB=0): the plain + // profiles never touch the pool, and this proves it costs them nothing. + if (getEnv("ARENA_DB") != "0") { + database { } + } + routing { get("/baseline11") { it.writeSum() } post("/baseline11") { it.writeSum(withBody = true) } @@ -113,9 +138,38 @@ fun main(args: Array) { get("/pipeline") { it.response.text("ok") } get("/delay/{ms}") { it.writeDelay() } get("/json/{count}") { it.writeItems(items) } + + // async-db: async Postgres sequential scan (no index on price) → + // {count, items:[{..., active:bool, tags:[...], rating:{score,count}}]}. + get("/async-db") { it.writeDbItems() } + + // fortunes: TechEmpower template benchmark — all fortune rows + one + // runtime row, sorted by message, rendered as escaped HTML. + get("/fortunes") { it.writeFortunes() } + + // 8gbit: read the posted body through the standard API and write it + // back verbatim — not from Content-Length, so chunked echoes too. + post("/echo") { it.echoBody() } + + // static-tls / static-h2: serve the mounted files with pre-compressed + // .br/.gz variants selected off Accept-Encoding by the framework. + staticFiles("/static", STATIC_DIR) { precompressed = true } } - onReady { startH2cListener(this) } + onReady { + // Each listener is awaited to its bind before READY returns, so the + // harness never probes a TLS port that is not up yet. A listener that + // fails to bind fails the launch rather than leaving a silent gap. + check(startListener(this, H2C_PORT, null)) { "h2c listener failed to bind on $H2C_PORT" } + if (TLS_ENABLED) { + check(startListener(this, H1TLS_PORT, TlsSettings(CERT_PATH, KEY_PATH, listOf("http/1.1")))) { + "h1+TLS listener failed to bind on $H1TLS_PORT" + } + check(startListener(this, H2TLS_PORT, TlsSettings(CERT_PATH, KEY_PATH, listOf("h2", "http/1.1")))) { + "h2+TLS listener failed to bind on $H2TLS_PORT" + } + } + } } } @@ -159,6 +213,94 @@ private suspend fun HttpContext.writeItems(items: ArenaItems) { response.write(items.render(request.pathParam("count"), request.queryParam("m"))) } +/** + * /async-db?min=&max=&limit=: rows from Postgres selected by price range. There is + * no index on price, so this is a sequential scan — the point of the profile. The + * body is built straight to bytes; `tags` is a JSONB column whose text is already a + * valid JSON array, so it is embedded verbatim. + */ +private suspend fun HttpContext.writeDbItems() { + val min = request.queryParam("min")?.toIntOrNull() ?: 0 + val max = request.queryParam("max")?.toIntOrNull() ?: Int.MAX_VALUE + val limit = (request.queryParam("limit")?.toIntOrNull() ?: 1).coerceIn(0, 1000) + val rows = dbContext().fetchAll( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + + "FROM items WHERE price BETWEEN :min AND :max LIMIT :limit", + mapOf("min" to min, "max" to max, "limit" to limit), + ) + val sb = StringBuilder(64 + rows.size * 160) + sb.append("{\"count\":").append(rows.size).append(",\"items\":[") + for (i in rows.indices) { + val r = rows[i] + if (i > 0) sb.append(',') + sb.append("{\"id\":").append(r.int("id")) + sb.append(",\"name\":"); appendJsonString(sb, r.string("name")) + sb.append(",\"category\":"); appendJsonString(sb, r.string("category")) + sb.append(",\"price\":").append(r.int("price")) + sb.append(",\"quantity\":").append(r.int("quantity")) + sb.append(",\"active\":").append(r.boolean("active")) + sb.append(",\"tags\":").append(r.string("tags")) + sb.append(",\"rating\":{\"score\":").append(r.int("rating_score")) + sb.append(",\"count\":").append(r.int("rating_count")).append("}}") + } + sb.append("]}") + response.contentType = "application/json; charset=utf-8" + response.write(sb.toString().encodeToByteArray()) +} + +/** + * /fortunes: every row of the fortune table plus one row injected at request time, + * sorted by message, rendered as an HTML table with each message HTML-escaped + * (the seeded row 11 carries a raw