diff --git a/frameworks/neton-hyper/.gitignore b/frameworks/neton-hyper/.gitignore new file mode 100644 index 000000000..92e903f66 --- /dev/null +++ b/frameworks/neton-hyper/.gitignore @@ -0,0 +1,2 @@ +# Runtime output: the entry writes its log files next to the binary. +logs/ diff --git a/frameworks/neton-hyper/Dockerfile b/frameworks/neton-hyper/Dockerfile new file mode 100644 index 000000000..1145e8902 --- /dev/null +++ b/frameworks/neton-hyper/Dockerfile @@ -0,0 +1,29 @@ +# Kotlin/Native compiles to a native executable, so the runtime image carries no +# JDK — only the binary and its libc. The JDK below is Gradle's, not the app's. +# +# The build runs ./gradlew, not the image's own gradle: the wrapper pins the +# exact Gradle the entry is verified against, so the container cannot silently +# build with a different one. +FROM eclipse-temurin:21-jdk AS build +WORKDIR /app + +# Warm the Gradle distribution, the Kotlin/Native toolchain and the Maven +# dependencies before the sources land, so editing Main.kt does not re-download +# the konan distribution. +COPY gradlew ./ +COPY gradle ./gradle +COPY build.gradle.kts settings.gradle.kts gradle.properties ./ +RUN ./gradlew --no-daemon -q dependencies --configuration linuxX64CompileKlibraries 2>/dev/null || true + +COPY src ./src +RUN ./gradlew --no-daemon -q linkReleaseExecutableLinuxX64 + +FROM debian:bookworm-slim +WORKDIR /app +COPY --from=build /app/build/bin/linuxX64/releaseExecutable/neton-httparena.kexe /app/server +# Read at startup from the working directory; it sets the arena's port map and +# connection ceiling and outranks the DSL. +COPY config ./config +# 8080 HTTP/1.1, 8082 HTTP/2 cleartext (prior knowledge). +EXPOSE 8080 8082 +CMD ["/app/server"] diff --git a/frameworks/neton-hyper/README.md b/frameworks/neton-hyper/README.md new file mode 100644 index 000000000..27654d554 --- /dev/null +++ b/frameworks/neton-hyper/README.md @@ -0,0 +1,41 @@ +# neton-hyper + +Neton 1.0.0-beta22 with Hyper4k (Rust Tokio + Hyper). + +The engine is selected explicitly, alongside the framework core, logging, HTTP and routing: + +```kotlin +implementation("com.netonstream:neton-http-hyper4k:1.0.0-beta22") +``` + +Both entries build entirely from Maven Central. Main.kt, compiler, GC and request +admission settings are identical; only Engine.kt and the engine dependency differ. +No local repository, composite build or benchmark-only business fast path is used. + +## Ports + +| Port | Protocol | +|---|---| +| 8080 | HTTP/1.1 | +| 8082 | HTTP/2 cleartext (prior knowledge) | +| 8081 | HTTP/1.1 TLS | +| 8443 | HTTP/2 TLS (ALPN) | + +All listeners serve one route table. TLS starts when the harness mounts certificates. + +## Running it outside the container + +The harness mounts the dataset at `/data/dataset.json`. To run on a developer +machine, point `ARENA_DATASET` somewhere writable: + +```bash +./gradlew linkReleaseExecutableMacosArm64 +ARENA_DATASET=../../data/dataset.json ./build/bin/macosArm64/releaseExecutable/neton-httparena.kexe +``` + +## Comparison + +Run neton and neton-hyper baseline separately on the same runner, repeating in +alternating order. Do not run both servers at once. Existing profile subscriptions +are retained, but the first requested comparison is baseline; new-engine +high-load results are not yet known. diff --git a/frameworks/neton-hyper/build.gradle.kts b/frameworks/neton-hyper/build.gradle.kts new file mode 100644 index 000000000..51f323851 --- /dev/null +++ b/frameworks/neton-hyper/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + kotlin("multiplatform") version "2.4.0" + kotlin("plugin.serialization") version "2.4.0" +} + +repositories { + mavenCentral() +} + +// Same framework release and business path in both entries; only the engine differs. +// All dependencies resolve from Maven Central, without local repositories or source substitution. +val netonVersion = "1.0.0-beta22" + +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" + // Preserve the existing Linux link policy in both entries for this comparison. + // The Hyper4k entry links Rust-backed engine and database static libraries. + if (target.konanTarget.family == org.jetbrains.kotlin.konan.target.Family.LINUX) { + linkerOpts("--allow-multiple-definition") + } + } + } + + sourceSets { + // This entry drives the engine adapter directly for its second listener + // (:8082), and that adapter is native-only, so the code lives in + // nativeMain rather than commonMain. + val nativeMain by creating { + dependsOn(commonMain.get()) + dependencies { + implementation("com.netonstream:neton-core:$netonVersion") + implementation("com.netonstream:neton-logging:$netonVersion") + implementation("com.netonstream:neton-http:$netonVersion") + implementation("com.netonstream:neton-routing:$netonVersion") + implementation("com.netonstream:neton-http-hyper4k:$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 + // response layer then re-encodes, is worth about 18% of the JSON + // path at the item counts this profile uses. Same serializer, same + // pipeline — only the intermediate String goes away. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.0") + } + } + macosArm64Main.get().dependsOn(nativeMain) + linuxX64Main.get().dependsOn(nativeMain) + linuxArm64Main.get().dependsOn(nativeMain) + } +} diff --git a/frameworks/neton-hyper/config/application.conf b/frameworks/neton-hyper/config/application.conf new file mode 100644 index 000000000..daaa6aad3 --- /dev/null +++ b/frameworks/neton-hyper/config/application.conf @@ -0,0 +1,46 @@ +# HTTP Arena configuration for the Neton entry. +# +# application.conf outranks the DSL, so the arena's port map and connection +# ceiling are set here rather than in code. + +[application] +name = "neton-httparena" +debug = false + +[server] +port = 8080 +host = "0.0.0.0" + +[http] +# 0 disables the per-request timeout. The wrapper costs two coroutine objects and +# their JobSupport state transitions on every request, and every arena profile +# already carries its own deadline, so a second one on the server buys nothing +# here. Shutdown draining is unaffected — the adapter keeps its own 5s grace. +timeout = 0 +# Ceiling on requests in flight, not on connections: the framework's admission +# gate answers 503 the moment it is reached. +# +# It has to clear what the profiles actually offer, which for h2 is connections +# times streams, not connections: +# +# baseline / limited-conn 4096 x 1 = 4,096 +# async 32000 x 1 = 32,000 +# pipelined 4096 x 16 = 65,536 +# json-h2c 4096 x 32 = 131,072 +# baseline-h2c 4096 x 100 = 409,600 <- peak +# +# The previous 65,536 was the pipelined figure and nothing more; baseline-h2c at +# 4096 connections offered 409,600 and the entry shed 411,799 requests as 5xx, +# 55.7% of the run. The reported rps counts 2xx only, so the failures did not +# show up in the number — they were only visible in the saved status counters. +# +# 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 +# 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. +level = "WARN" diff --git a/frameworks/neton-hyper/config/database.conf b/frameworks/neton-hyper/config/database.conf new file mode 100644 index 000000000..9105508ae --- /dev/null +++ b/frameworks/neton-hyper/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-hyper/gradle.properties b/frameworks/neton-hyper/gradle.properties new file mode 100644 index 000000000..47aba36b7 --- /dev/null +++ b/frameworks/neton-hyper/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g diff --git a/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.jar b/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.properties b/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..aaaabb3cb --- /dev/null +++ b/frameworks/neton-hyper/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frameworks/neton-hyper/gradlew b/frameworks/neton-hyper/gradlew new file mode 100755 index 000000000..23d15a936 --- /dev/null +++ b/frameworks/neton-hyper/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frameworks/neton-hyper/gradlew.bat b/frameworks/neton-hyper/gradlew.bat new file mode 100644 index 000000000..5eed7ee84 --- /dev/null +++ b/frameworks/neton-hyper/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frameworks/neton-hyper/meta.json b/frameworks/neton-hyper/meta.json new file mode 100644 index 000000000..4c77e75b0 --- /dev/null +++ b/frameworks/neton-hyper/meta.json @@ -0,0 +1,38 @@ +{ + "display_name": "neton-hyper", + "language": "Kotlin", + "engine": "hyper", + "type": "emerging", + "mode": "standard", + "completeness": { + "routing": true, + "middleware": true, + "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, 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": [ + "baseline", + "limited-conn", + "pipelined", + "async", + "latency-1m", + "latency-10k", + "latency-500k-8cpu", + "baseline-h2c", + "json-h2c", + "json-comp", + "json-tls", + "8gbit", + "static-tls", + "baseline-h2", + "static-h2", + "async-db", + "fortunes" + ], + "maintainers": [ + "zoujiaqing" + ] +} diff --git a/frameworks/neton-hyper/settings.gradle.kts b/frameworks/neton-hyper/settings.gradle.kts new file mode 100644 index 000000000..4e0b59a2c --- /dev/null +++ b/frameworks/neton-hyper/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "neton-httparena" diff --git a/frameworks/neton-hyper/src/nativeMain/kotlin/Engine.kt b/frameworks/neton-hyper/src/nativeMain/kotlin/Engine.kt new file mode 100644 index 000000000..7f20bef95 --- /dev/null +++ b/frameworks/neton-hyper/src/nativeMain/kotlin/Engine.kt @@ -0,0 +1,5 @@ +import neton.core.http.adapter.HttpAdapter +import neton.core.http.adapter.HttpServerConfig +import neton.http.hyper4k.Hyper4kHttpAdapter + +fun createEngine(config: HttpServerConfig): HttpAdapter = Hyper4kHttpAdapter(config) diff --git a/frameworks/neton-hyper/src/nativeMain/kotlin/Main.kt b/frameworks/neton-hyper/src/nativeMain/kotlin/Main.kt new file mode 100644 index 000000000..10284e4d1 --- /dev/null +++ b/frameworks/neton-hyper/src/nativeMain/kotlin/Main.kt @@ -0,0 +1,476 @@ +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 +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.io.encodeToSink +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import neton.core.KotlinApplication +import neton.core.Neton +import neton.core.component.NetonContext +import neton.core.config.getEnv +import neton.core.config.readConfigFile +import neton.core.http.HttpContext +import neton.core.http.HttpStatus +import neton.core.http.adapter.HttpServerConfig +import neton.database.dbContext +import neton.database.adapter.sqlx.SqlxDatabase +import neton.database.config.DatabaseConfig +import neton.database.config.DatabaseDriver +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import neton.http.http +import neton.http.static.staticFiles +import neton.core.http.adapter.TlsSettings +import neton.routing.* + +/** + * Neton's HTTP Arena entry. Engine.kt selects the transport; business routes are + * identical in the neton and neton-hyper entries. + * + * The arena diffs response bytes, so every handler commits through HttpResponse + * directly. A committed response is written out verbatim — the dispatcher only + * wraps a handler's *return value* in its {"code":0,"message":"OK","data":…} + * envelope, and there is nothing left to wrap once the body is on the wire. + * + * Two listeners share one route table: :8080 for the HTTP/1.1 profiles and + * :8082 for the h2c ones. hyper4k answers HTTP/1.1 and HTTP/2 cleartext on the + * 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 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. + */ + +/** + * Arena port map: 8080 HTTP/1.1, 8082 HTTP/2 cleartext (prior knowledge). + * Both are fixed by the harness (`scripts/lib/common.sh`), which runs containers + * on the host network and points its load generators at those numbers. + * + * The h1 port already came from application.conf; ARENA_H2C_PORT gives the second + * listener the same treatment, so running the entry on a developer machine does not + * have to occupy the harness ports. The harness sets neither. + */ +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. + * + * ARENA_DATASET overrides it so the entry can be run outside the container, + * where `/data` is not creatable on a developer machine; the harness sets no + * such variable, so measured runs always read the mount. + */ +private val DATASET_PATH = getEnv("ARENA_DATASET") ?: "/data/dataset.json" + +/** + * Kotlin/Native starts with a 10 MiB target heap and a 5 MiB floor. Autotune + * raises the target under load, but from that floor it collects constantly on a + * workload that allocates per request, and the mutators spend their time parked + * on the collector's locks rather than serving. Profiles of this entry are + * dominated by `safePointActionImpl` and by threads blocked in + * `pthread_mutex_lock`, which is what that looks like from the outside. + * + * The floor is 1 GiB because the benchmark said so, and a smaller one was tried + * and cost real throughput. At 128 local connections every floor from 64 MiB to + * 1 GiB measured the same and resident memory simply tracked the floor, which + * made 256 MiB look free; on the arena's 4096 connections it was not. Dropping + * to 256 MiB cost 33% on latency-1m, 27% on pipelined and 19% on async — the + * three profiles with the largest live set, which are exactly the ones that have + * to spend part of a five-second run growing the heap back before they can go + * fast. Autotune does climb past the floor here (baseline settles near 468 MiB, + * baseline-h2c at 4096 connections near 1.5 GiB); it just cannot climb for free. + * + * Memory efficiency is a separate, optional dimension on the board. Throughput + * is the ranking, so the floor is sized for throughput. + */ +@OptIn(NativeRuntimeApi::class) +private fun tuneGc() { + GC.minHeapBytes = 1L * 1024 * 1024 * 1024 + GC.targetHeapBytes = 2L * 1024 * 1024 * 1024 +} + +fun main(args: Array) { + tuneGc() + val items = ArenaItems.load(DATASET_PATH) + + Neton.run(args) { + http(::createEngine) { + port = H1_PORT + } + + // The Postgres pool (sqlx4k) stands up its own Rust/Tokio runtime; doing that + // at startup made the DB-free profiles (baseline etc.) share the box with a + // second multi-threaded runtime. Initialise it lazily on the first DB request + // instead, so the plain profiles never pay for it. async-db/fortunes take a + // one-time init on their first hit. + + routing { + get("/baseline11") { it.writeSum() } + post("/baseline11") { it.writeSum(withBody = true) } + + // The h2 shape of the same arithmetic, served on :8082. + get("/baseline2") { it.writeSum() } + + 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 { + // 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" + } + } + } + } +} + +/** + * /baseline11 and /baseline2: the sum of the two query parameters, plus the + * request body when there is one. Plain text, no envelope, no trailing newline. + */ +private suspend fun HttpContext.writeSum(withBody: Boolean = false) { + val a = request.queryParam("a")?.toIntOrNull() ?: 0 + val b = request.queryParam("b")?.toIntOrNull() ?: 0 + val body = if (withBody) request.text().trim().toIntOrNull() ?: 0 else 0 + response.text((a + b + body).toString()) +} + +/** + * /delay/{ms}: wait the requested milliseconds, then echo the parameter back. + * + * The wait is per request, so overlapping requests each carry their own timer — + * the arena fires 32 concurrent delays and diffs every one of them against the + * value it asked for. + */ +private suspend fun HttpContext.writeDelay() { + val raw = request.pathParam("ms") ?: "0" + val millis = raw.toLongOrNull() + if (millis == null || millis < 0) { + response.status = HttpStatus.BAD_REQUEST + response.text("invalid delay: $raw") + return + } + // 0 is a valid delay, not a missing one: it answers immediately. + if (millis > 0) delay(millis) + response.text(raw) +} + +/** /json/{count}?m=M: the first `count` dataset items, each carrying its total. */ +private suspend fun HttpContext.writeItems(items: ArenaItems) { + // The bytes are what goes on the wire, so build them directly. Going through + // `response.json(String)` would serialize into a String and then encode that + // String to UTF-8 — a second full pass over the payload for nothing. + response.contentType = "application/json; charset=utf-8" + 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 val dbMutex = Mutex() + +@kotlin.concurrent.Volatile +private var dbReady = false + +/** Lazily stand up the Postgres pool on the first DB request; idempotent. */ +private suspend fun ensureDb() { + if (dbReady) return + dbMutex.withLock { + if (dbReady) return + SqlxDatabase.initialize( + DatabaseConfig( + driver = DatabaseDriver.POSTGRESQL, + uri = "postgresql://bench:bench@localhost:5432/benchmark", + ), + ) + dbReady = true + } +} + +private suspend fun HttpContext.writeDbItems() { + ensureDb() + 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