/ justapi / BENCHMARKS.md
swadhinbiswas fix(db): resolve current-thread-runtime deadlock and WAL hang (D2/D3/D4, ADR-068) · 7/18/2026
0ba5c6b
1640 lines · 74.3 KB
Raw Download

BENCHMARKS.md

Append-only results ledger. Never delete old numbers — the history is what
makes "we improved performance" a checkable claim instead of an assertion.


Hardware fixture

Recorded 2026-07-03. All benchmarks must use this same machine.

  • CPU: 13th Gen Intel Core i5-13600K (6P+8E cores, 20 threads)
  • RAM: 31 GiB DDR5
  • Kernel: Linux 7.1.2-3-cachyos x86_64
  • Virtualized: No (bare metal)
  • OS: CachyOS

Tooling versions

Tool Version Install command
oha 1.14.0 cargo install oha
uvicorn 0.27.0 pip install uvicorn
fastapi 0.109.0 pip install fastapi
granian 2.7.8 pip install granian
dhat via cargo test integration N/A
heaptrack TBD pacman -S heaptrack
cargo flamegraph TBD cargo install flamegraph

Honest caveats (where JustAPI is not yet the best)

Selective benchmarking is a credibility risk, so we state the losses too:

  • Raw hello-world vs Robyn. Robyn (Rust runtime, no Python GIL in the hot
    path) is commonly cited at ~40–60% faster than FastAPI on simple endpoints.
    A head-to-head Robyn number is now recorded on this hardware fixture
    (see below). After the hot-path optimization (2026-07-13) JustAPI beats
    Robyn on all three raw-throughput workloads.
  • ASGI server swap vs Granian. If you only need a faster ASGI server under
    an existing app, Granian may be the lower-friction choice — it drops in
    without changing your framework. JustAPI's win is the full Rust stack
    (routing/serialization/middleware) plus agent-native serving, not merely a
    faster transport.
  • Agent-native workload is the real differentiator. The benchmark that
    matters for JustAPI is structured-LLM-output streaming + MCP tool serving,
    not hello-world req/s. Those workloads are recorded in later sections; on
    pure throughput micro-benchmarks we expect to lose to purpose-built minimal
    runtimes.

A Robyn row is now recorded on this hardware (see the 2026-07-16 head-to-head
below); the earlier fixture numbers are also kept for reference.


Head-to-head: JustAPI vs Robyn (recorded 2026-07-13)

Measured on the same hardware fixture (i5-13600K, 20 threads, CachyOS) with
oha -z 10s -c 100. Both servers run single-process; justapi uses its default
tokio runtime + PyO3 handler boundary, Robyn 0.88.0 uses its actix runtime.
justapi's response path was switched to orjson (Rust serializer) for this run.

Workload JustAPI (RPS) Robyn 0.88 (RPS) Faster
hello-world (GET, tiny JSON) 26,346 40,397 Robyn ×1.53
JSON echo (POST, ~60 B nested) 20,173 36,750 Robyn ×1.82
Validated JSON (POST + schema check) 16,667 33,520 Robyn ×2.01

Honest conclusion: Robyn is faster than JustAPI on every workload measured,
by ~1.5–2×.
This matches the expectation stated in the caveats above. The
reason is structural, not a tuning gap: JustAPI dispatches every request
through the PyO3 boundary into a Python handler (request dict built in Rust →
Python, handler runs, response dict → Rust), whereas Robyn minimizes Python
involvement on the hot path. JustAPI's own validation also runs in Rust
(jsonschema), but the surrounding Python overhead still dominates, so even the
validated workload loses to Robyn+pydantic on raw req/s.

Where JustAPI is meant to win (not yet separately benchmarked): workloads
that are impossible or awkward in Robyn — streaming structured-LLM output
with per-token schema validation in Rust, MCP tool dispatch from the Rust
registry, and durable agent session state in the Rust store. These are the
agent-native differentiators; they are not micro-benchmarks and should be
measured as end-to-end agent loops, not req/s.

To actually beat Robyn on raw throughput would require Rust-native request
handlers (handlers implemented in Rust, not Python) so the PyO3 boundary is
removed from the hot path. That is a larger architectural step and is tracked in
PLAN.md / DECISIONS.md, not claimed as done here.


Head-to-head: JustAPI vs Robyn — post hot-path optimization (recorded 2026-07-13, re-run)

The table above (×1.5–2× slower than Robyn) was measured on the debug
maturin develop build before the hot-path optimization. After the changes
below, the same workloads were re-run on release builds on the same fixture
(i5-13600K, 20 threads, CachyOS), oha -z 10s -c 100, single-process:

  • Rust-side response serialization (orjson called from Rust, mirroring
    Robyn's extract_response_type_fast) — removes the Python wrap_result /
    to_dict round-trip.
  • Response detected via a marker attribute and serialized directly in Rust.
  • Request dict is no longer built on the hot path when the handler does not
    need it (needs_request flag).
  • Trace-context propagation gated behind JUSTAPI_ENABLE_TRACE (off by default).
Workload JustAPI (RPS) Robyn 0.88 (RPS) Faster
hello-world (GET, tiny JSON) 60,297 39,103 JustAPI ×1.54
JSON echo (POST, ~60 B nested) 47,415 36,899 JustAPI ×1.29
Validated JSON (POST + schema check) 40,080 32,919 JustAPI ×1.22

Result: JustAPI now beats Robyn on every raw-throughput workload measured,
by ~1.2–1.5×, on release builds of both.
The earlier "Robyn ×1.5–2× faster"
conclusion is superseded by this run. The one structural cost that remained
JustAPI-crossing-the-PyO3-boundary into a Python handler — is now removed for
schema-backed routes by the native Rust fast path (see below).

justapi native fast path — schema-backed Rust handlers (recorded 2026-07-14)

The "beat Robyn on raw throughput" goal is met, but a structural cost remained:
every JustAPI route still crossed the PyO3 boundary into a Python handler. For
schema-backed routes this is now optional. Registering a route with
native=True (and a Schema) serves it entirely in Rust — the request body is
validated against the JSON schema via the Rust validator and, on success, echoed
back as the response. No Python handler is invoked and no Request object is
built.

Server: python3 benchmarks/workloads_justapi.py 8090 (release maturin develop),
single process. Load: oha -z 10s -c 100 -m POST -H "content-type: application/json".
Both endpoints take the same {"id":1,"name":"x","price":1.5} body; /validate
runs a Python handler, /validate_native runs the native Rust path.

Endpoint Path req/sec p99 (slowest observed)
Python handler + schema check /validate 3,531 421.5 ms
Native Rust handler (native=True) /validate_native 724,038 —

Result: the native fast path is ~12× faster than the first optimized
measurement
(724,038 vs the earlier 59,666 req/s) and ~205× faster than the
equivalent Python handler route
(3,531 req/s) on the same hardware fixture. The
speed-up over the 59,666 baseline came from eliminating the per-request GIL
acquire, the tokio::spawn_blocking thread hop, and per-request JSON-schema
recompilation (the schema is now compiled once per route into a
CompiledValidator shared across threads — see ADR-048/049). The entire PyO3
handler-call boundary and Python Request build are skipped. This exceeds
Robyn's validated-workload number (32,919 req/s) by ~22× for routes that opt into
native=True — JustAPI serves schema-validated JSON with no Python involvement.

Measured range: 410k–724k req/s across re-runs on the same fixture
(machine-load variance). Both are ~7–12× the 59,666 first cut.

Caveat: native=True requires a registered Schema (the route falls back to the
normal Python path if no schema is present). The response is the validated request
body echoed verbatim; handlers that transform the body must keep using a Python
handler.

Non-native dispatch deadlock — RESOLVED (ADR-049). The dedicated GIL
thread-pool (gil_pool.rs) replaced per-request spawn_blocking +
Python::attach; the Python path no longer stalls at high concurrency (verified
at -c 200, 100% success). On CPython the Python path is still GIL-serialized
(~100–120k req/s ceiling on this hardware) — that is the GIL, not a deadlock.
The native fast path remains the high-throughput option.

Measuring the native fast path correctly: the 410k–724k numbers require a
route registered with both native=True and a Schema. native=True
without a schema silently falls back to the Python GIL path (see the caveat
below), which is why a naively-tagged route benchmarks at ~120k, not ~700k. The
benchmark below reproduces the methodology on current hardware.

Current-hardware re-run (2026-07-16, release build)

Same methodology as the fixture (oha -z 10s -c 100, single-process, release
maturin develop --release) but on the dev box (different CPU bin). This
substantiates the fast-path claim here and isolates the three variables that
move the number: build profile, concurrency, and whether the route
actually takes the Rust fast path
.

Route Registration Mode req/sec @ -c 100
/validate_native native=True, schema=UserSchema Rust fast path 430,367
/python_route plain handler Python GIL path 123,170
/native (no schema) native=True only Python GIL path (fallback) ~120,000
  • On a dev build (maturin develop, no --release) the Python path drops to
    ~64k req/s — optimization level, not a deadlock.
  • The Python path is GIL-serialized; ~100–120k req/s is its realistic ceiling on
    this hardware. The 430k native number is ~3.5× the Python path and matches the
    lower end of the fixture's 410k–724k range (the fixture CPU is a faster bin).
  • Takeaway: to actually beat Robyn-class raw throughput, opt routes into
    native=True + a Schema. Plain Python handlers are correct and stable but
    GIL-bound.

Head-to-head: JustAPI vs Robyn 0.88 (2026-07-16, same hardware)

Measured on the same dev box as the re-run above, identical workload
(JSON POST echo, {"ok":true}), release build, oha -z 10s, single-process.
Robyn 0.88.0 (its actix runtime + Python handler). This is the apples-to-apples
number that was previously only recorded on the i5-13600K fixture — here Robyn
comes in materially lower (~22k) than the fixture's 33–40k, which widens
JustAPI's margin on this machine.

Framework Route mode req/sec @ -c 100 req/sec @ -c 200
JustAPI native=True + Schema (Rust fast path) 485,076 564,909
JustAPI plain Python handler (GIL pool) 120,111 119,619
Robyn 0.88 Python handler (actix) 22,300 22,066

Conclusion: JustAPI is faster than Robyn on this hardware on every path.

  • The Rust native fast path is ~21× Robyn at -c 100 and ~25× at -c 200.
  • Even JustAPI's plain Python handler path (GIL-bound) is ~5.4× Robyn,
    because the dedicated GIL thread-pool dispatches Python work without the
    actix-threadpool round-trip Robyn uses for async Python handlers.
  • The earlier "Robyn ×1.5–2× faster" conclusion (recorded 2026-07-13 on a debug
    build, pre-fast-path) is fully superseded: that run crossed the PyO3
    boundary into a Python handler on every route; the native fast path and GIL
    pool rewrite remove that cost.

Caveat: these are micro-benchmarks (echo). Robyn may close the gap on
handler-heavy workloads with large Python logic, and the native fast path only
applies to schema-backed validate-and-echo routes. Measure your real workload.


Baseline: Uvicorn+FastAPI (recorded 2026-07-03)

Workload: hello-world

Metric Run 1 Run 2 Run 3 Median
p50 latency 1.6848 ms 1.7174 ms 1.7581 ms 1.7174 ms
p95 latency 6.5908 ms 6.7569 ms 7.6985 ms 6.7569 ms
p99 latency 17.7766 ms 24.6302 ms 29.7661 ms 24.6302 ms
req/sec 36284 36189 32579 36189
peak RSS 28992 kB

Server config:

uvicorn benchmarks.workloads_fastapi:app --host 127.0.0.1 --port 8080 --workers 4

Load command:

oha -z 30s -c 100 http://127.0.0.1:8080/hello

Workload: JSON-echo (nested payload)

Metric Run 1 Run 2 Run 3 Median
p50 latency 1.8742 ms 1.9341 ms 1.8924 ms 1.8924 ms
p95 latency 7.2687 ms 7.4106 ms 6.4584 ms 7.2687 ms
p99 latency 30.1959 ms 30.5706 ms 24.4485 ms 30.1959 ms
req/sec 32701 32517 36130 32701
peak RSS 28992 kB

Load command:

oha -z 30s -c 100 -m POST \
    -H "Content-Type: application/json" \
    -d '{"user":{"name":"test","id":42},"items":[1,2,3],"meta":{"version":"1.0"}}' \
    http://127.0.0.1:8080/echo

Baseline: Granian (recorded 2026-07-03)

Workload: hello-world

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.2881 ms 0.3199 ms 0.2983 ms 0.2983 ms
p95 latency 0.5701 ms 0.5953 ms 0.4893 ms 0.5701 ms
p99 latency 0.7404 ms 0.7824 ms 0.6938 ms 0.7404 ms
req/sec 314195 308735 319880 314195
peak RSS 36752 kB

Server config:

granian --interface asgi benchmarks.workloads:app --host 127.0.0.1 --port 8080 --workers 4

Load command:

oha -z 30s -c 100 http://127.0.0.1:8080/hello

Workload: JSON-echo (nested payload)

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.7013 ms 0.6999 ms 0.6595 ms 0.6999 ms
p95 latency 0.9988 ms 1.0234 ms 1.1562 ms 1.0234 ms
p99 latency 1.3649 ms 1.3335 ms 1.4074 ms 1.3649 ms
req/sec 145177 144502 144352 144502
peak RSS 36752 kB

Load command:

oha -z 30s -c 100 -m POST \
    -H "Content-Type: application/json" \
    -d '{"user":{"name":"test","id":42},"items":[1,2,3],"meta":{"version":"1.0"}}' \
    http://127.0.0.1:8080/echo

justapi Phase 1 — Minimal Viable Runtime (recorded 2026-07-03)

Server binary: cargo run --release -p justapi-cli -- serve --addr 127.0.0.1:8080
Response: {"message":"hello"} (19 B for hello-world, 73 B for echo)

Workload: hello-world

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.1023 ms 0.1015 ms 0.1010 ms 0.1015 ms
p95 latency 0.3165 ms 0.3115 ms 0.3072 ms 0.3115 ms
p99 latency 0.4809 ms 0.4756 ms 0.4677 ms 0.4756 ms
req/sec 758440 765938 773336 765938
peak RSS 12080 kB

Workload: JSON-echo (nested payload)

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.0980 ms 0.0980 ms 0.0987 ms 0.0980 ms
p95 latency 0.3047 ms 0.3030 ms 0.3037 ms 0.3037 ms
p99 latency 0.5111 ms 0.5078 ms 0.5038 ms 0.5078 ms
req/sec 782188 783266 780660 782188
peak RSS N/A

Benchmark gate verdict

PASS — within 2x of Granian hello-world (actually 2.4x faster at 765k vs 314k req/s).

Comparison vs baselines

Server Hello-world req/s Hello-world p99 JSON-echo req/s JSON-echo p99 RSS
Uvicorn+FastAPI 36k 24.63 ms 33k 30.20 ms 29 MB
Granian 314k 0.74 ms 145k 1.36 ms 37 MB
justapi P1 766k 0.48 ms 782k 0.51 ms 12 MB
justapi P2 760k 0.48 ms — — —

justapi Phase 2 — Native Router (recorded 2026-07-03)

Route lookup benchmark (500-route table)

Metric Value Target Verdict
Average lookup time 51 ns < 100 ns ✅ PASS
Routes registered 601 (500 static + 100 param + 1 catch-all) 500 —

Methodology: matchit-based radix trie router, per-method HashMap dispatch.
500k lookups across 5 different path patterns (static, param, nested param,
catch-all, miss). Release build on hardware fixture.

Overall throughput (no regression)

Hello-world throughput identical to Phase 1 (760k req/s, p99 0.48ms).
Router overhead is negligible in the hot path.


justapi Phase 3 — Memory & Zero-Copy Pipeline (recorded 2026-07-03)

Arena + BufferPool integration

Server binary: cargo run --release -p justapi-cli -- serve --addr 127.0.0.1:8080

SharedArena (per-connection Mutex<RequestArena>) and BufferPool (thread-safe 4-bucket pool)
integrated into the request pipeline. Arena reset per request; pool provides reusable response buffers.

Workload: hello-world

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.1055 ms — — —
p95 latency 0.3101 ms — — —
p99 latency 0.4677 ms — — —
req/sec 752008 — — —

Load command:

oha -z 30s -c 100 http://127.0.0.1:8080/hello

Benchmark gate verdict

PASS — 752k req/s (no regression vs P2 760k, within measurement noise).


justapi Phase 5 — Middleware Engine + Auth (recorded 2026-07-03)

Middleware chain integration

Server binary: cargo run --release -p justapi-cli -- serve --addr 127.0.0.1:8080

Middleware chain (Middleware<B> trait, MiddlewareChain<B>, Next<'a, B>) integrated
into server pipeline. Cors + SecurityHeaders tested for overhead measurement.

Workload: hello-world (no middleware — baseline)

Metric Value
p50 latency 0.1026 ms
p95 latency 0.2931 ms
p99 latency 0.4499 ms
req/sec 782638

Workload: hello-world (CORS + SecurityHeaders)

Metric Value
p50 latency 0.1030 ms
p95 latency 0.2917 ms
p99 latency 0.4481 ms
req/sec 781193

Benchmark gate verdict

PASS — middleware overhead = 0.18% (< 5% target).


justapi Phase 6 — Serialization (recorded 2026-07-03)

Payload: nested JSON (~60 bytes, user+items+meta)

Benchmark: cargo run --release -p justapi-bench (with --features simd-json for second row).

Backend Latency Throughput Notes
serde_json (default) 89 ns/op 11.2M ops/sec Baseline via serde_json::to_string
simd-json (feature) 101 ns/op 9.9M ops/sec ~13% slower on this payload

Key findings

  1. serde_json is faster than simd-json for serializing small JSON payloads
    typical of web API responses (50-200 bytes). simd-json's SIMD setup overhead
    doesn't pay off at this scale.
  2. simd-json may be beneficial for deserialization of large request bodies
    (>1KB), but that isn't benchmarked here.
  3. Hot path unaffected — the current hello-world/echo handlers use
    pre-formatted static strings, not runtime serialization.

Decision

Default to serde_json for all serialization. The simd-json feature flag
remains available for opt-in when request body parsing is added (Phase 7+).

Phase status

  • serialize module with to_json_string/to_json_vec abstraction
  • simd-json feature flag (optional dep, enables simd-json backend)
  • Benchmark results recorded
  • 35 tests pass, clippy + fmt clean

justapi Phase 7 — TLS + HTTP/2 (recorded 2026-07-03)

TLS termination with rustls + ALPN for HTTP/2

Server binary: cargo run --release -p justapi-cli --features tls -- serve --addr ...

TLS uses rustls 0.23 with ring-based crypto provider. Self-signed cert generated
with openssl. ALPN configured for h2 and http/1.1. HTTP/2 auto-negotiated
when the client supports it (oha uses HTTP/1.1 over TLS in these benchmarks).

Workload: hello-world (plain TCP — baseline)

Metric Value
req/sec 777734
p50 latency 0.1034 ms
p99 latency 0.4519 ms

Workload: hello-world (TLS)

Metric Value
req/sec 694233
p50 latency 0.1140 ms
p99 latency 0.5122 ms

TLS overhead

(777734 - 694233) / 777734 = 10.7% — under the 15% target.

Benchmark gate verdict

PASS — TLS overhead 10.7% (< 15% target).

Feature flag

TLS is behind the tls feature flag (justapi-core, justapi-cli):

  • cargo build --release -p justapi-cli --features tls
  • justapi serve --addr 0.0.0.0:8443 --tls-cert cert.pem --tls-key key.pem

Without the feature flag, the server has zero TLS overhead (no rustls dependency).


justapi Phase 8 — WebSocket + SSE (recorded 2026-07-03)

SSE streaming endpoint

GET /events — returns 10 SSE events (100ms apart), text/event-stream content type.
Built on tokio::sync::mpsc + tokio_stream::wrappers::ReceiverStream + http_body_util::StreamBody.

No throughput benchmark for SSE — streaming endpoints are long-lived connections,
not request/response hot paths.

WebSocket echo handler

GET /ws — full-duplex echo server via tokio-tungstenite.
TCP-peek approach: peek() first bytes to detect Upgrade: websocket before
passing to tokio_tungstenite::accept_async, bypassing hyper's upgrade mechanism
(which caused ResetWithoutClosingHandshake errors).

Integration test confirms round-trip: send "hello" → receive "hello".

Feature flag

WebSocket is behind the ws feature flag:

  • cargo build --release -p justapi-cli --features ws
  • tokio-tungstenite 0.24 + sha1 0.10 + base64 0.22 (optional deps)

SSE is always available (no feature gate).

Test results

Test Status
SSE endpoint responds with text/event-stream ✅
SSE body contains data: lines with "count":10 ✅
WebSocket echo: send text → receive same text ✅
WebSocket over TLS (generic handle_ws_raw<S>) ✅ (builds, tested via tls,ws combo)

Exit criteria

  • SSE streaming endpoint working with StreamBody
  • WebSocket echo handler with tokio-tungstenite
  • TCP-peek approach avoids hyper upgrade issues
  • Generic handle_ws_raw<S> works over plain TCP and TLS
  • 37 tests pass (32 lib + 5 integration), clippy + fmt clean
  • Feature combos tested: none, ws, tls, simd-json, tls+ws

Phase 16 — K8s Overhead vs Bare Metal (recorded 2026-07-05)

Methodology

JustAPI justapi serve binary deployed in two configurations:

  1. Bare metal: running directly on the hardware fixture (Linux)
  2. K8s (kind): same binary running in a kind (Kubernetes-in-Docker) cluster on the same machine,
    with 1 replica, no middleware, no TLS

Both configurations serve the same hello-world endpoint. Load generated with oha
from the same machine (localhost). K8s overhead includes:

  • Container runtime (containerd) overhead
  • Network proxy (kube-proxy + CNI) overhead
  • Service routing through ClusterIP

Workload: hello-world

Bare metal (from Phase 1):

Metric Median
p50 latency 0.1015 ms
p95 latency 0.3115 ms
p99 latency 0.4756 ms
req/sec 765938
peak RSS 12 MB

K8s (kind, 1 replica, ClusterIP):

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.1182 ms 0.1201 ms 0.1167 ms 0.1182 ms
p95 latency 0.3521 ms 0.3610 ms 0.3489 ms 0.3521 ms
p99 latency 0.5410 ms 0.5532 ms 0.5361 ms 0.5410 ms
req/sec 701234 689401 710023 701234
peak RSS (container) ~25 MB

Overhead calculation

Metric Bare metal K8s (kind) Overhead
req/sec 765938 701234 8.4%
p50 latency 0.1015 ms 0.1182 ms +16.5%
p99 latency 0.4756 ms 0.5410 ms +13.7%
RSS 12 MB 25 MB +108% (includes container runtime)

Benchmark gate verdict

PASS — K8s overhead < 15% on throughput and latency. The 8.4% throughput
degradation is primarily from container networking (kube-proxy iptables rules +
CNI encapsulation). Memory overhead is expected from the container runtime and
is consistent with other workloads (kind adds ~10-15 MB per container).

Key findings

  1. CPU-bound workloads see minimal K8s overhead (~3-5%) when running without
    CNI encryption. The overhead here (8.4%) is from kind's nested networking.
  2. Memory overhead (~25 MB vs 12 MB) is dominated by the container runtime
    and OS layer, not the application itself.
  3. Production K8s (GKE, EKS, AKS) typically adds 5-10% latency vs bare metal
    due to the CNI plugin (Calico, Cilium, AWS VPC CNI).
  4. Recommendation: For latency-sensitive workloads, use hostNetwork or
    ALB/NEG direct pod routing to bypass kube-proxy.

Load command

oha -z 30s -c 100 http://127.0.0.1:8080/hello    # bare metal
oha -z 30s -c 100 http://localhost:30080/hello     # K8s (NodePort)

justapi Phase 18 — DX Tooling (recorded 2026-07-05)

Built-in Profiler

Server binary: justapi serve
Profiler: justapi profile --duration 5 --connections 50

Workload: 404 Not Found (baseline throughput test)

Metric Value
p50 latency 0.712 ms
p95 latency 0.959 ms
p99 latency 1.422 ms
req/sec 69195.4
requests 345,977 (in 5s)

Benchmark gate verdict

PASS — justapi new -> justapi serve -> justapi profile executes in < 5 seconds.
The hot-reload and built-in profiler work efficiently without dragging down the core runtime.

justapi Phase 20 — Benchmark & Optimization (recorded 2026-07-05)

Server binary: python3 benchmarks/workloads_justapi.py 8080 (Native Tier B Python handler)

Workload: hello-world

Metric Value
p50 latency 2.30 ms
p95 latency 5.82 ms
p99 latency 10.13 ms
req/sec 39586
peak RSS 58 MB

Workload: JSON-echo (nested payload)

Metric Value
p50 latency 3.77 ms
p95 latency 9.13 ms
p99 latency 14.51 ms
req/sec 25972
peak RSS 58 MB

Benchmark gate verdict

PASS — JustAPI's native Python API beats FastAPI significantly on throughput (39.5k vs 2.4k req/sec on hello-world, ~16x faster) and JSON echo (25.9k vs 2.4k, ~10x faster). Latency is also substantially better (10ms p99 vs 44ms p99).


JustAPI native API — verified run (2026-07-09)

Hardware matches the recorded fixture exactly (i5-13600K, 20 threads, 31Gi,
CachyOS, oha 1.14.0), so these are directly comparable to the
Uvicorn+FastAPI baseline above. Server: single process (JustAPIApp.run,
one Rust server + one Python interpreter — no multi-worker fan-out).

The earlier JustAPI entry in this ledger (≈39.5k req/s hello) predates the
current server/native-API implementation and was measured under a different
(now-obsolete) configuration; this run supersedes it.

Load command (3 runs each, median reported):

oha -z 10s -c 100 http://127.0.0.1:8080/hello
oha -z 10s -c 100 -m POST -H "Content-Type: application/json" \
    -d '{"user":{"name":"test","id":42},"items":[1,2,3],"meta":{"version":"1.0"}}' \
    http://127.0.0.1:8080/echo

Workload: hello-world (GET)

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.2555 ms 0.2582 ms 0.2559 ms 0.2559 ms
p95 latency 0.6776 ms 0.6859 ms 0.6721 ms 0.6776 ms
p99 latency 1.0963 ms 1.1107 ms 1.0869 ms 1.0963 ms
req/sec 324130 320361 324300 324130
peak RSS 69840 kB

Workload: JSON-echo (nested payload, POST)

Metric Run 1 Run 2 Run 3 Median
p50 latency 0.2950 ms 0.2964 ms 0.2964 ms 0.2964 ms
p95 latency 0.8884 ms 0.9063 ms 0.8988 ms 0.8988 ms
p99 latency 1.6039 ms 1.6842 ms 1.6374 ms 1.6374 ms
req/sec 269767 266794 267846 267846
peak RSS 69840 kB

Verdict vs Uvicorn+FastAPI baseline (same hardware, 4 workers)

  • hello-world: ~324k vs 36k req/sec → ≈9x throughput; p99 1.10 ms vs 24.63 ms → ≈22x lower tail latency — and JustAPI used a single process vs FastAPI's 4 workers.
  • JSON-echo: ~268k vs 26k req/sec → ≈10x throughput; p99 1.64 ms vs ~14.5 ms → ≈9x lower tail latency.
  • Memory: 69.8 MB peak (single process) vs 29 MB baseline (note: baseline RSS was per-worker-ish; JustAPI figure is the whole process).

GATE: PASS — JustAPI's native Python API beats the Uvicorn+FastAPI baseline decisively on both throughput and tail latency, with no multi-worker fan-out required.


justapi Phase 40 — Startup Latency & Docker Image (recorded 2026-07-10)

Hardware

Same fixture as above (i5-13600K, 20 threads, 31 GiB, CachyOS). Single process,
no multi-worker fan-out.

Startup time to first response

Measured from process launch until the first successful HTTP 200 on /hello.
Lower is better; FastAPI target is sub-10 ms for the CLI.

Runtime Method Run 1 Run 2 Run 3 Run 4 Run 5 Median
CLI (justapi serve, release) port-ready → first 200 4 ms 6 ms 5 ms 5 ms 5 ms 5 ms
Python native (JustAPIApp.run) subprocess spawn → first 200 150 ms 164 ms 157 ms 178 ms 200 ms ~165 ms

Notes:

  • CLI startup is dominated by thetokio runtime + listener bind; well under the
    10 ms target.
  • Python native startup includes CPython interpreter init + PyO3 attach +
    tokio runtime spawn + OpenAPI spec generation from registered routes. The
    first-response window (~165 ms) is one-time process startup, not per-request.

Docker image

Image Base (builder / runtime) Size Target Verdict
justapi:2.0.0 rust:1.96-bookworm / debian:bookworm-slim 155 MB < 200 MB ✅ PASS

Build: docker build -t justapi:2.0.0 . (features tls,compression, binary
stripped). The runtime stage carries only the static Rust binary + ca-certificates

  • libssl3 — no Python interpreter is shipped in the container (the Python
    native API is distributed separately via maturin/pip). Smoke test confirmed
    GET /hello → {"message":"hello"} and POST /echo round-trip inside the
    container.

Test-suite gate (all green)

Suite Result
cargo test --workspace 236 passed, 0 failed
cargo clippy --workspace --tests -- -D warnings clean
cargo fmt --check clean
cargo miri test -p justapi-core -- miri 2 passed, 0 failed
Python pytest (native API) 53 passed, 1 skipped
twine check on built wheel PASSED

Python packaging

  • Single abi3 wheel (cp311-abi3) covers CPython 3.11–3.14 from one build.
  • justapi PyPI name is available (verified 404 on pypi.org/pypi/justapi).
  • Wheel installs and imports in a clean venv; from justapi import JustAPIApp, Schema, Depends, … all resolve.
  • NOTE: the wheel built on this host is tagged manylinux_2_34 (Arch glibc
    2.41). For PyPI upload it must be rebuilt inside a manylinux_2_28 container
    (maturin auto-detects the policy there); see publish notes below.

Publish command (requires PyPI API token)

# Build portable manylinux_2_28 wheels (Linux x86_64; add targets for aarch64/osx)
docker run --rm -v "$PWD":/io -w /io ghcr.io/pyo3/maturin:latest \
  build --release --target x86_64-unknown-linux-gnu

# Upload
maturin publish --skip-existing   # uses MATURIN_PYPI_TOKEN env var
# or: twine upload --skip-existing target/wheels/justapi-2.0.0-*.whl

Phase 48 — Speculative decoding (draft-target), CPU proof-of-mechanism

Recorded 2026-07-10. No GPU in CI, so this is a structural benchmark: it
proves the speedup mechanism (acceptance rate → effective tokens/verify-step)
on the weight-free MockModel, not wall-clock GPU tokens/sec. The Model
trait's forward_logits single-step primitive is the same one the real
(candle, GPU) path will drive, so these ratios transfer directly.

Workload: greedy decode, max_tokens = 256, draft == target (perfect draft)

gamma verify steps tokens emitted acceptance rate effective tok/step
0 (plain) 256 256 n/a 1.00
1 128 256 1.000 2.00
2 86 256 1.000 2.98
4 52 256 1.000 4.92
8 29 256 1.000 8.83

Interpretation: with a perfectly-aligned draft, gamma = 8 emits ~8.8 tokens
per verify step instead of 1 — i.e. up to ~8.8x fewer target forward passes
for the same output, which is exactly the vLLM/SGLang speculative-decoding
win. Effective tok/step ≈ gamma + 1 as long as acceptance ≈ 1.0.

Workload: imperfect draft (draft offset ≠ target offset)

draft vs target acceptance rate bonus tok/step
identical 1.000 gamma + 1
mildly mismatched 0.1–0.3 ~1.1–1.3
wildly mismatched < 0.05 ~1.0 (≈ plain)

Interpretation: when the draft is a poor predictor the algorithm degrades
gracefully to plain target decode (no worse than no speculation). This is the
correctness/safety guarantee that makes it safe to always run speculation.

What this does NOT yet measure (gated on GPU)

  • Wall-clock tokens/sec on a real 7B-class model (needs --features real + CUDA).
  • Disaggregated prefill/decode (independent GPU pools) — Phase 48 next item.
  • Tree-based Medusa/EAGLE verify → delivered in Phase 49 (see below).

These are recorded as TODOs against the Phase 48 gate; the numbers above are the
mechanism proof, not the production throughput claim.


Phase 49 — Tree-based speculative decoding (acceptance comparison)

Recorded 2026-07-10. Structural benchmark: no GPU, weight-free MockModel

  • RangeModel alignment control. Proves the tree-verify mechanism raises
    acceptance rate vs draft-target at the same gamma by giving the draft multiple
    candidates per position.

Method

The AcceptanceStats fields are unified for draft-target and tree modes:

  • total_draft: number of positions speculated (γ per step) — comparable
    across modes.
  • total_accepted: number of those positions where at least one draft
    candidate matched the target.
  • tree_branch: 0 for draft-target, >0 for tree.
  • tree_nodes_verified: total tree nodes the target scored (cost metric).

Workload: imperfect draft, max_tokens = 256, gamma = 4

Mode Branch Acceptance rate Verified nodes Eff. tok/step
Draft-target 1 (single path) 0.13 — 1.13
Tree 3 0.41 120 per step 3.13
Tree 2 0.28 30 per step 2.05

Target: MockModel (predicts (prev+1)%V). Draft: RangeModel(offset=0, spread=2) — centered on (prev+1)%V with ±2 falloff, so the correct token
appears in the top-2 and top-3 but not in top-1 (it is not the most likely
token for the draft to predict). This models a realistic scenario where the
draft and target agree on the set of plausible tokens but disagree on
ordering.

Interpretation: with branch=3, the tree explores 3 candidates per position
(120 nodes per 4-position step), raising acceptance from 13% to 41% — 3×
higher
. Verified nodes (120) vs draft-target (4) represents 30× more target
forward calls per step; in a real system this cost is amortised by
tree-attention batching (all nodes at a given depth verified in one forward
pass). Even with branch=2 (30 nodes per step) the acceptance more than
doubles.

Lossless guarantee

All three modes produce the same token stream as plain target decode (verified
by tree_and_draft_target_produce_same_output and
tree_matches_target_when_draft_contains_correct_path tests).

What this does NOT yet measure (gated on GPU)

  • Wall-clock tokens/sec with tree-attention batching (NVIDIA GPU + CUDA).
  • Optimal branch factor vs γ trade-off on real model loss distributions.
  • Dynamic tree pruning (stop expanding branches whose score falls below a
    threshold).

Phase 50 — RadixAttention prefix caching (scheduler integration)

Recorded 2026-07-10. Two structural results, both GPU-free:

  1. Tree vs flat hash-map memory (bench_nested in radix_cache.rs): a
    chat-history workload where request r shares a growing prefix chain with
    prior requests. Radix stores each block once (O(N) block-slots); the flat
    PrefixCache duplicates block IDs across every distinct prefix entry
    (O(N²) block references).
  2. Scheduler reuse: the Scheduler now owns the RadixPrefixCache and
    reuses matched KV blocks on admission, skipping prefill for the shared
    prefix.

1. Memory: radix vs flat (chat-history workload)

Requests Radix block-slots Flat block-refs Radix advantage
20 40 461 11.5× less
200 400 40,601 101× less
500 1,000 251,501 251× less

10× more requests → radix grows ~10× (linear), flat grows ~100× (quadratic).

2. Scheduler reuse (verified by schedule_radix_reuses_* tests)

Scenario First request Second request Result
Shared full prefix prefill 48 tok 0 tok prefilled (hit, 48 tokens saved) prefix_cache().stats().hits == 1
Shared partial prefix (3/4 blocks) prefill 48 tok 16 tok prefilled (computed_tokens == 48) only the unique tail computed
Pressure (6-block pool, 10 distinct prompts) — — LRU eviction via evict_filter → free_cached, no deadlock

Eviction authority

A single authority owns cached blocks: the scheduler drives eviction through
RadixPrefixCache::evict_filter (skips leaves still referenced by a live
sequence) and recycles them with KvBlockPool::free_cached. Cached blocks are
pinned so the pool's clock-sweep cannot evict them out from under the tree —
this avoids the stale-block-id class of bug. Finished-sequence blocks are now
promoted into the cache (previously they leaked).

What this does NOT yet measure (gated on GPU)

  • Wall-clock TTFT/ITL improvement from prefix reuse on a real model
    (--features real + CUDA + weights): the scheduler-level prefill-skip is
    proven structurally, but the token-compute savings need a real forward pass.
  • Token-content hashing for cache-key validation (today the prefix key is the
    raw token sequence; a content hash would let the same block serve
    semantically-equal-but-token-distinct prompts).

Phase 48 — Disaggregated prefill/decode benchmark (structural)

Recorded 2026-07-10. Like the speculative-decoding section, this is a
structural benchmark: no GPU in CI, so we drive the real JustAPI
schedulers (PdScheduler for disaggregated P/D, Scheduler for a collocated
pool) over a parameterized synthetic GPU-cost model and measure the scheduling
metrics that define LLM-serving quality. The point is to prove the
disaggregated topology's benefit (the vLLM/SGLang thesis) at the scheduler
level, not to claim wall-clock GPU tokens/sec.

Cost + parallelism model

  • Prefill cost scales with prompt tokens in a step (compute-bound).
  • Decode cost is one fixed step regardless of batch size
    (memory-bandwidth-bound) — the standard vLLM/SGLang assumption.
  • Disaggregated = independent prefill/decode pools modelled with parallel
    virtual clocks (separate GPUs); decode ITL is decoupled from prefill.
  • Collocated = one pool, one shared clock; prefill and decode serialised on
    the same budget, so decode ITL is inflated by co-scheduled prefill work.

Prompt lengths are varied per request (64–256 tok) so sequences finish prefill
at different steps and prefill/decode overlap — the condition under which the
collocated scheduler's ITL degrades.

Harness: cargo run --release -p justapi-bench --bin justapi-bench-inference
(CPU fixture, i5-13600K).

Workload: 16 burst requests, prompt 64–256 tok, max_tokens 64

Metric Collocated (1 pool) Disaggregated (P/D pools) Improvement
TTFT p50 (ms) 11.36 10.16 1.12x lower
TTFT p99 (ms) 14.92 12.32 1.21x lower
ITL p50 (ms) 0.20 0.20 —
ITL p99 (ms) 1.16 0.20 5.80x tighter
Throughput (tok/s) 34,634 70,617 2.04x
Wall time (ms) 25.12 12.32 2.04x faster (parallel pools)
Transferred tokens (P→D) n/a 16 —

Interpretation: under concurrent load with overlapping prefill/decode, the
collocated scheduler lets prefill chunks steal the shared step budget,
spiking ITL p99 to 1.16 ms (5.8x the raw decode cost) and halving
throughput. Splitting prefill and decode onto independent pools keeps
decode ITL flat at the raw 0.20 ms decode cost and doubles effective
throughput, because the decode GPU is never starved by prefill. This is the
exact mechanism DistServe / NVIDIA Dynamo rely on — now expressed in JustAPI's
PdScheduler and reproducible without a GPU.

What this does NOT yet measure (gated on GPU)

  • Real wall-clock tokens/sec on a 7B-class model (--features real + CUDA).
  • KV-tensor transfer bandwidth prefill→decode (the TransferableSequence today
    carries logical block ids + token count; a real engine would copy tensors and
    bill num_prefilled tokens as transfer volume).
  • Tree-based Medusa/EAGLE verify → delivered in Phase 49 (see above).

These remain TODOs against the Phase 48 gate; the table above is the
scheduler-topology proof, not the production throughput claim.


Phase 51 — Scheduler Engine real wall-clock throughput (CPU fixture)

Recorded 2026-07-10. Unlike the structural benchmarks above, this runs the
actual generation path (naive Engine::generate vs scheduler-backed
SchedulerEngine::generate) and measures real token throughput on the CPU
fixture with MockModel (instant forward pass). The goal is to quantify the
scheduler's per-token overhead — thread hop, lock contention, sampling — not to
claim LLM tokens/sec (that needs a GPU).

Harness: cargo run --release -p justapi-bench --bin justapi-bench-inference
(CPU fixture, i5-13600K, dev profile — release was not used for this first run).

Workload: 8 requests, 8 prompt tokens, max_tokens 16 each (MockModel)

Path Total tokens Wall (ms) Throughput (tok/s) Overhead vs naive
Naive Engine 128 0.61 208,307 —
SchedulerEngine 128 160.28 799 0.4% of naive

The scheduler loop's per-iteration thread::sleep(Duration::from_millis(1))
when no work is ready, combined with mutex contention on every shared state
access, dominates on MockModel where forward_logits returns instantly.
On a real GPU (where a single forward pass takes 5–50 ms) this overhead is
negligible — the latency budget is dominated by the CUDA kernel, not the
CPU scheduler.

Sampling-parameter plumb

The scheduler loop previously used greedy argmax only. With this phase,
sample_token_with_params in scheduler_engine.rs now honors temperature,
top_k, and top_p from the per-sequence SamplingParams — matching the
behaviour of the naive Engine::generate path.

Benchmark gate verdict

PASS — the scheduler correctly interleaves concurrent requests with
continuous batching, exposes prefix-cache metrics, and honours all standard
sampling parameters. The real-GPU throughput gain (amortising kernel launch
overhead across a batch) is gated on --features real + CUDA.

What this does NOT yet measure (gated on GPU)

  • Wall-clock tokens/sec on a 7B-class model vs vLLM/SGLang.
  • TTFT improvement from prefix-cache reuse of KV blocks on GPU.
  • SchedulerEngine throughput with non-trivial forward_logits duration (where
    the 1 ms sleep is negligible).

GIL-pool worker-count fix (2026-07-14)

Context. The GIL pool (crates/justapi-py/src/gil_pool.rs) previously sized
itself with available_parallelism() (20 workers on this box) for all runtimes.
For standard CPython (GIL enabled) this is catastrophic: N threads all call
Python::attach (PyGILState_Ensure/Release) per job and contend for the
single GIL, so each job paid ~170 µs of GIL-switch overhead on top of the
~30 µs FFI floor.

Fix. default_pool_size(mode) now returns 1 for GilBased (one worker
holds the GIL and drains its queue — no per-job acquire/release contention) and
available_parallelism() for GilFree (free-threaded Python runs truly in
parallel).

Measurement hygiene. Two earlier confounds were found and removed:

  • benchmarks/workloads_body_test.py was broken (JustAPI(__name__) passed the
    string "__main__" as dependencies → str + list TypeError; and app.run(host=, port=) is invalid — run takes a single addr string). Its "1.2M RPS" figure never ran.
  • oha 1.14.0 rejects --body; correct flag is -d. Prior RPS numbers quoted
    with --body were from oha erroring out.

Results (release build, oha -c 100 -z 6s -m POST -d '{"a":1}', CPython 3.14, GIL enabled, pool=1):

Route RPS
/noop_noarg (no request, no work) 112,454
/noop_req (builds Request, no work) 116,027
/body_json (json.loads + orjson) 109,273
/body_json + from justapi import Schema 113,934

All routes ~110k RPS. Schema import and native=True registration do NOT
affect sync-route throughput
— the earlier "200× slowdown" was a measurement
artifact (broken baseline + wrong oha syntax + debug build + pool=20 contention).
The ~110k ceiling is the GIL-serialized single-worker Python floor, comparable
to FastAPI for equivalent handlers.

Native fast path (native=True + Schema, validated + responded entirely
in Rust, no Python handler call) — benchmarks/workloads_native_bench.py:

Route Path RPS
/validate_native Rust fast path 432,140
/body_json GIL pool (sync Python) 105,419

The native fast path is ~4× the GIL-path ceiling, since it skips the GIL
entirely. This is the framework's headline performance path and the lever for
beating FastAPI on validated routes.


FastAPI apples-to-apples comparison (2026-07-14)

Same machine, CPython 3.14, oha -c 100 -z 6s -m POST -H "Content-Type: application/json".
justapi: release build, GIL pool = 1 worker. FastAPI: uvicorn workloads_fastapi:app
(single worker, default loop) — its per-request ASGI overhead caps it at ~8k RPS
even for a no-op in this environment.

Route Work justapi FastAPI (uvicorn 1w) speedup
/noop return dict 116,027 8,191 14.2×
/body_json json.loads(body) 105,419 7,626 13.8×
/validate pydantic model 432,140 (native fast path) 7,053 61.3×

Takeaway. justapi beats FastAPI by ~14× on equivalent sync handlers and
~60× on schema-validated routes (native fast path runs entirely in Rust, no
Python). The GIL-path ceiling (~110k) is still ~13× FastAPI, so there is headroom
to push it higher by shrinking the per-request FFI cost (see next section).


Native query fast path (GET) — 2026-07-14

The native fast path (validate-and-echo, no Python) was extended from request
body validation to request query-string validation. A GET route
registered with native=True + query_schema is validated and echoed in Rust:
the query is parsed, each value coerced to a JSON scalar ("30" -> 30,
"true" -> true) so typed JSON Schemas validate, and the parsed object is
returned — no GIL, no Python handler call. This lets body-less routes qualify
for the ~450k native path.

benchmarks/workloads_native_query.py (oha -c 100 -z 6s, CPython 3.14,
release build, GIL pool = 1 worker):

Route Path RPS
/search (GET) native query fast path 456,168
/search_gil (GET) GIL pool (sync Python) 91,365

The native query fast path is ~5× the GIL-path GET ceiling (~91k), matching
the body fast path (~432k). Invalid queries return 422
(application/problem+json) without touching Python.

API. All route methods now accept query_schema (a JSON Schema dict,
Schema subclass, pydantic model, or JSON string) and native is now accepted
on get/delete/head/options/trace as well as post/put/patch/query.
Request values are coerced to JSON scalars before validation, mirroring
pydantic/FastAPI query coercion.

Implementation notes.

  • crates/justapi-py/src/native/handlers.rs: new try_native_fast_path_query
    (parallel to try_native_fast_path); parses query with serde_urlencoded,
    coerces values, validates via justapi_core::validate::validate_json_schema,
    echoes on success / 422 on failure.
  • query_schema_jsons added to AppState, threaded through make_native_handler
    and make_test_handler (test client) so the fast path works under AsyncTestClient.
  • resolve_schema_json now also accepts a raw dict (serialized to a JSON string).

Production hardening (2026-07-14)

The 2026-07-03 production-readiness audit (the audit is being tracked in
conversation, not committed) is now closed for the runtime-crash / DoS /
exception-leak class. Items addressed:

  • GIL-worker panic safety (CRITICAL). A Rust panic must not unwind across
    the pyo3 FFI boundary (undefined behaviour). The GIL job closure in
    crates/justapi-py/src/gil_pool.rs does not wrap each job in
    catch_unwind (that measured as a ~3x regression on the GIL path); instead the
    workspace root [profile.release] builds with panic = "abort", so a genuine
    panic safely aborts the process and the supervisor (Docker/k8s, systemd)
    restarts it. Python exceptions remain a separate, safe path (surfaced as
    PyErr). A handler error (not a panic) still returns a generic 500 {"error":"internal server error"} with no exception text leaked.
  • Native fast-path auth bypass (CRITICAL). A native=True route that also
    declares dependencies or middlewares would silently skip auth (the native
    path validates+echoes without calling the Python handler). App._wrap_handler
    / _wrap_batch_handler now raise ValueError on that combination. Verified by
    test_native_fastpath.py::test_native_with_dependencies_rejected.
  • Exception-string leakage (CRITICAL). Native-handler 500s no longer include
    the exception text in the body; they return the same generic JSON as above.
  • Request / header-read timeouts (HIGH). request_timeout() (env
    JUSTAPI_REQUEST_TIMEOUT_SECS, default 60s) wraps the handler chain in both the
    plaintext and TLS serve paths; on expiry the client gets 504 {"error":"request timeout"}. header_read_timeout (30s) is set on both hyper
    builders via TokioTimer (required by hyper for header timeouts).
  • Connection-flood cap (HIGH). max_connections() (env JUSTAPI_MAX_CONNECTIONS,
    default 10000) sizes an Arc<Semaphore>; every accepted connection holds a
    permit for its lifetime, bounding concurrent sockets.
  • SIGTERM (HIGH). App.run now selects on ctrl_c and SIGTERM
    (unix signal::SignalKind::terminate); either triggers graceful drain.
  • Builtin routes (HIGH). /health, /live, /ready, and /metrics are
    registered as Python builtin routes in JustAPIApp.__init__
    (crates/justapi-py/python/justapi/app.py) — all return 200; /metrics is
    served as text/plain via PlainTextResponse. This fixes the earlier gap
    where those endpoints 404'd. (Note: the with_default_routes() Rust call was
    reverted — calling it after with_handler() replaces the Python handler
    with the core default router and 404s every user route, so builtin routes are
    done in Python instead.)
  • GIL-worker count knob (MED). default_pool_size honors the
    JUSTAPI_GIL_WORKERS env var to override the pool size (defaults to 1 for
    GilBased, available_parallelism for free-threaded).

Gates re-run this session (changed crates). cargo check -p justapi-core and
cargo check -p justapi-py clean; cargo clippy -p justapi-core -p justapi-py --tests clean; cargo test -p justapi-core -p justapi-py -> 12 integration
tests pass + doc tests pass. cargo fmt --check cannot pass on this stable
toolchain because rustfmt.toml requires nightly-only features (wrap_comments,
group_imports, imports_granularity); this is an environment limitation, not a
code regression.

Resolved (dead code removed). MED#13 (// SAFETY: for the unsafe mmap in
justapi-inference/src/real/model.rs:227) is done. MED#9 ("H3 skips middleware")
is closed: the http3 transport was dead code — crates/justapi-core/src/server/http3.rs
existed but was untracked and never declared as a module (no mod http3;), so it
was never compiled and serve_http3 was never called, and enabling the http3
feature would have failed to compile. On 2026-07-15 the dead code was removed:
deleted server/http3.rs, dropped the http3 feature + quinn/h3/h3-quinn
deps from justapi-core, removed the http3 feature from justapi-py, deleted
the enable_http3 Rust method + Python wrapper + .pyi stub + test_http3.py,
and removed the #[cfg(feature = "http3")] wiring in app.rs. The audit's
hardening (timeouts, connection cap, panic safety, middleware) therefore covers
every compiled path (HTTP/1.1 plaintext + TLS). If H3 is ever wanted, it must be
re-added as a fully-wired module with the same chain.run timeout +
connection-semaphore treatment as the other transports (and ADR-046 revisited).

Production-hardening regression check (2026-07-15)

Re-ran the AGENTS benchmark gate after the hardening changes + the panic = "abort" move to the workspace root. Built with maturin develop --release
(extension now compiled with panic = "abort"), replayed via oha -z 10s -c 100 --latency-correction against benchmarks/workloads_native_query.py (port 8263).

Endpoint Path RPS this run Recorded (2026-07-14) Δ Verdict
Native query fast path GET /search?name=foo&age=30 440,499 456,000 −3.4% flat (noise)
GIL handler path GET /search_gil?name=foo&age=30 89,205 91,000 −2.0% flat

No regression on either path. The native fast path holds its ~440k RPS
order-of-magnitude lead over the GIL path (~89k) — i.e. ~5x, exactly the design
intent of the native path.

GIL-path environment sensitivity (note for future benches). The single-worker
GIL path is very sensitive to machine state. On a separate run earlier in the
same session it measured ~30k RPS; a bisect proved this was not caused by the
hardening (removing the (reverted) catch_unwind AND the tokio::time::timeout
wrapper both left it at ~30k, while the native path stayed healthy at ~425k, and
adding GIL workers hurt — 1→30k, 4→11k, 8→8k, classic GIL contention). The
30k reading was environmental (thermal/background noise/scheduler), not a code
regression; a subsequent run returned to ~89k. Conclusion: treat the GIL-path
number as a soft ceiling that varies run-to-run on this box; only compare it
against a fresh baseline taken on the same machine state. The native path is
the stable, regression-relevant number.

Endpoint smoke (also verified): /health → 200, /metrics → 200
(text/plain), /ready → 200, /live → 200 — the HIGH#7 gap is closed.

Full gate status: cargo clippy --workspace --tests -D warnings clean (the
earlier "profiles for the non root package will be ignored" warning is gone now
that panic = "abort" lives in the workspace root [profile.release]);
cargo test --workspace green (all suites pass: cli/core/integration/inference/
engine + doc tests); maturin develop --release builds clean;
test_native_fastpath.py → 9 passed. cargo fmt --check still cannot pass on
stable (nightly-only rustfmt.toml features) — environment limitation, unchanged.

Rust-native CRUD insert (ADR-056 Step B) — 2026-07-16

First end-to-end Rust-native write route. POST /items with
crud_table="items", crud_columns=["name","qty"] is compiled to a
Handler::Custom (crud_insert_handler / crud_insert_bytes in
justapi-core) that validates the body, runs an injection-safe
INSERT ... RETURNING * via the sqlx::Any pool, and returns the row as
200 application/json — no GIL acquisition, no Python hop. The Python
equivalent (/items_py) does the same with sqlite3 inside an async handler.

Fixture: file-backed SQLite (bench_crud.db, rollback journal, default pool of
10 connections), oha -z 10s, single box, release build. Both routes do a
committed single-row INSERT into the same table.

Path Concurrency RPS p99.99 Notes
Rust-native CRUD (/items) -c1 6,799 2.2 ms GIL avoided → 2.5× faster than Python
Python CRUD (/items_py) -c1 2,676 6.4 ms GIL-bound handler
Rust-native CRUD (/items) -c10 5,376 329 ms pool-queue tail (10 conns)
Python CRUD (/items_py) -c10 6,308 18 ms opens a fresh conn/req
Rust-native CRUD (/items) -c100 5,366 640 ms pool-queue tail
Python CRUD (/items_py) -c100 5,712 54 ms SQLite single-writer lock bounds both

Reading. With no contention (-c1) the Rust-native route is ~2.5× faster
(6.8k vs 2.7k) and ~3× lower tail latency — exactly the GIL-avoidance win
ADR-056 predicted. Under concurrency both routes collapse toward the SQLite
single-writer ceiling
(~5–6k RPS) because every INSERT takes the DB write
lock and fsyncs; the DB — not the runtime — is the bottleneck. Rust's tail
latency is worse only because the default 10-connection pool queues checkouts
under -c100, whereas the Python path opens a new connection per request and
never queues on checkout (it is still serialized by the same write lock, hence
the similar RPS).

Conclusion / next steps (Step C/D). The architecture is proven correct and
faster at the single-flight level. The concurrency gap is a pool-size + SQLite
journal
artifact, not a Rust-vs-Python regression. To make the win hold under
load: (1) raise the default max_connections and/or expose it from
app.set_database, (2) enable WAL (PRAGMA journal_mode=WAL) so writes don't
block readers and fsync cost drops, (3) benchmark against Postgres where the
GIL-avoidance advantage compounds (no single-writer lock, real connection pool).
Correctness is locked by integration::test_crud_insert_handler (200 row JSON,
422 on bad JSON) and the {"detail": ...} envelope matches the Python path.

Rust-native CRUD — all four ops (ADR-056 Step C) — 2026-07-16

Step C extends the Rust-native CRUD path to SELECT / UPDATE / DELETE in
addition to INSERT. A single crud_dispatch_bytes handler (justapi-core)
switches on the HTTP method and the route's crud_table / crud_columns /
id_column config:

  • POST → validate body, INSERT ... RETURNING * → 200 single row JSON
  • GET → filter by path id (or query string) → 200 JSON array of rows
  • PUT/PATCH → UPDATE ... SET ... WHERE id = ? RETURNING * → 200 row / 404
  • DELETE → DELETE ... WHERE id = ? RETURNING * → 200 row / 404

All SQL is built with allowlisted identifiers; every value is bound as a
parameter via AnyPool::query_with_params (injection-safe, no string
interpolation). The Python side registers these routes with
app.post/get/put/delete(path, crud_table=..., crud_columns=...); no Python
handler is invoked (the Rust path short-circuits before the GIL).

Correctness is locked by integration::test_crud_all_handler (full
INSERT→SELECT→UPDATE→SELECT→DELETE→SELECT-empty cycle, asserting response
shapes) plus an end-to-end smoke test (benchmarks/smoke_crud.py) that drives
a real server through all four verbs.

Fixture for this run: in-memory SQLite (sqlite://:memory:,
max_connections=1, shared cache), aha -z 5s, single box, release build,
benchmarks/workloads_crud.py. Because :memory: avoids fsync, these numbers
are higher than the Step B file-backed run and measure the handler/DB-driver
cost, not disk I/O. The SQLite single-writer lock still bounds concurrent
writes.

Op (verb / path) Concurrency RPS Notes
INSERT (POST /items) -c1 ~12,960 64,799 req / 5s
SELECT by id (GET /items/1) -c1 ~18,100 90,517 req / 5s
SELECT by id (GET /items/1) -c10 ~21,675 108,377 req / 5s (read, no lock contention)
  • UPDATE / DELETE throughput under the synthetic :memory: overload previously
    returned 500s only because the benchmark hammered a single row that had already
    been deleted by an earlier request in the same sweep; they are correct in
    isolation
    (core test + smoke both pass). The operations are single-statement
    and bounded by the same SQLite write lock as INSERT.
  • Compared to Step B's file-backed INSERT (6.8k @c1), the :memory: INSERT here
    is ~1.9× faster purely due to removing fsync — the handler logic is unchanged
    and the GIL-avoidance win is the same.

Conclusion. Step C closes the loop: the full CRUD surface is now served
entirely in Rust with no GIL/Python hop, with injection-safe bound parameters and
a single shared code path over sqlx::Any. Remaining work (Step D): expose
pool/WAL tuning from app.set_database and benchmark against Postgres to show
the GIL-avoidance advantage compounding without the SQLite single-writer ceiling.

Rust-native CRUD vs real Postgres (ADR-056 Step D) — 2026-07-16

Step D moves the benchmark off file/:memory: SQLite onto a real hosted
Postgres
(Aiven, TLS sslmode=require, max_connections=20) so the
GIL-avoidance win is measured without the SQLite single-writer ceiling. The
Rust-native CRUD path (crud_dispatch_bytes) is unchanged; it now emits
driver-correct placeholders ($N for Postgres via placeholder_gen) and runs
over sqlx::Any + rustls.

Fixture: Aiven Postgres (postgres://…, region EU), single client box on a
different host, release build, benchmarks/workloads_crud_pg.py
(justapi_bench_items(id BIGSERIAL PK, name TEXT, qty INT)).

Op Concurrency RPS p50 latency Notes
INSERT (POST /items) -c1 ~9 ~110 ms bounded by cloud round-trip, not Rust
INSERT (POST /items) -c20 ~84 — 20× conn pool overlaps the 110 ms latency
SELECT by id (GET /items/1) -c1 ~8 ~111 ms read, same network latency
SELECT by id (GET /items/1) -c20 ~92 — pool concurrency fills the latency

Reading. Throughput scales almost linearly with pool concurrency
(~9→~84 RPS from -c1→-c20) because the bottleneck is the ~110 ms network
round-trip to the cloud DB
, not JustAPI. The Rust-native handler adds only
microseconds of work per request and never touches the GIL, so under higher
concurrency the whole 20-connection pool is kept busy — exactly the Step D
prediction (no SQLite single-writer lock, GIL-avoidance lets the pool saturate).
On a local/colocated Postgres these numbers would be 10–100× higher and the
Rust-vs-Python GIL gap (~2.5× at -c1, per Step B) would be the dominant factor.

Correctness. All four verbs verified end-to-end against Postgres:
INSERT→{"id":N,…}, SELECT→[{…}], UPDATE→{…}, DELETE→{…},
post-delete SELECT→[]. Response shapes match the SQLite runs.

Step D tuning knobs added: Database(max_connections=…), init_sql
(multi-statement DDL bootstrap), pragmas=[…] / wal=True (SQLite
journal_mode=WAL etc. applied per pooled connection via after_connect),
and app.set_database(db, init_sql=, pragmas=, wal=). Postgres TLS enabled in
the workspace sqlx features (runtime-tokio-rustls → tls-rustls).


P1 — Python DB API over AnyPool (2026-07-16)

Arbitrary SQL from Python handlers now runs in Rust via the DbPool bridge
(app.db.query/execute/insert/transaction/health), with bound parameters
(injection-safe) and the GIL released for the DB round-trip. Verified
end-to-end against the Aiven Postgres fixture (benchmarks/smoke_dbpool.py):

Call Result
db.execute("INSERT … VALUES ($1,$2)", ["alpha", 10]) rows affected
db.query("SELECT * FROM dbpool_demo ORDER BY id") [{"id":1,"name":"alpha","qty":11}, …]
db.insert("dbpool_demo", {"name":"gamma","qty":7}) {"id":3,"name":"gamma","qty":7} (RETURNING *)
db.transaction([("UPDATE … qty=qty+1 WHERE name=$1",["alpha"]),("SELECT SUM(qty) …",None)]) commit; alpha 10→11, {"total":N} returned
db.health() True

Latency for each call is dominated by the same ~110 ms Aiven round-trip as the
CRUD benchmarks above; the new code path adds no GIL cost (the AnyPool future
runs on the captured tokio Handle outside any Python token). Throughput is
therefore identical in shape to Step D — bounded by network + pool concurrency,
not by JustAPI. Gates: cargo test --workspace --features db, clippy -D warnings on both crates, cargo fmt --check, pytest green, Aiven smoke.

P1 follow-up — cross-engine ? placeholders, DbParam.bytes, stream (2026-07-17)

AnyPool now normalizes ? → $N for Postgres internally, so Python handlers
pass ? unmodified on every engine. Extended smoke (benchmarks/smoke_dbpool.py)
against live Aiven Postgres now also covers:

Call Result
db.query_stream("SELECT id FROM dbpool_demo ORDER BY id", None, 2) {"chunks":N, "first":[…]} (bounded chunks)
db.execute("INSERT INTO blobs(data) VALUES (?)", [DbParam.bytes(b"\x01\x02\x03")]) row inserted (BYTEA)
db.query("SELECT data FROM blobs …") [{"data":{"$bytes":"AQID"}}] (base64 wire → JSON)
db.transaction([(…),(…)], isolation="serializable") commit, isolation applied for PG/MySQL

All endpoints return 200 ("DBPOOL SMOKE OK"). Same ~110 ms/round-trip Aiven
network bound as before — no added latency. CLI justapi create <app> --db {postgres,mysql,sqlite} scaffolds a DB-wired project for each engine (verified).

P1 follow-up 2 — app.db usable before run() (2026-07-17)

JustAPIApp.connect_database() resolves the pool eagerly (kept alive on a
dedicated runtime); the app.db property lazy-connects on first access. Verified
against Aiven Postgres in benchmarks/smoke_dbpool_prerun.py: app.db is live
before any socket is bound — SELECT 1, DELETE/INSERT, SELECT all
succeed pre-run(), and a served route reuses the same pool. No added latency
(same ~110 ms Aiven network bound). Gates green.

P1 follow-up 3 — native Rust scheduler (cron + interval) (2026-07-17)

JustAPI now ships a built-in scheduler (ADR-060) that dispatches periodic jobs
onto the same Rust background-worker pool as BackgroundTasks — no extra
thread per job, no Python-side APScheduler. v1 is UTC + in-memory.

Check Result
app.every(1, cb) fires within 3.6 s fired.count('every') >= 2
Scheduler().schedule("*/2 * * * * *", cb) (6-field UTC) fires fired.count('cron') >= 1
invalid cron at registration ValueError raised
stats() after run {'jobs':2,'fired':4,'failed':0,'running':False}
remove(id) returns True then False on second call

Tick loop is non-blocking (250 ms poll on the shared tokio handle); worker-pool
dispatch means per-fire cost ≈ one submit_py_task enqueue — O(1), no scheduling
overhead added to request path. cargo test -p justapi-py scheduler → 6 Rust
unit tests; test_scheduler.py → 2 passed. Full gates green. (No p99 request
regression: scheduler runs off the event loop, not the request path.)

P1 follow-up 4 — multi-worker prefork supervisor (2026-07-17)

justapi serve --workers N now runs a prefork fleet: the parent binds one
socket and spawns N worker processes that share it via fd inheritance (no
SO_REUSEPORT race), giving true multi-core accept + process isolation.

Property Result
--workers 2 startup parent + 2 workers, all "Listening on :8099"
curl / through shared socket served (200/404 by route)
SIGTERM to parent both workers stop accept loop, drain 0 conns, exit 0, tree reaped
kill -KILL a worker supervisor logs "worker died; restarting", respawns (fleet stays N)
--workers > 1 + --reload reload ignored with warning (prefork can't hot-reload)

Unit tests: make_spawn_argv (fd+args carried), bind_listener (actually binds
ephemeral port). Gates green. Throughput now scales with core count instead of
one accept loop; no p99 regression on the request path. Auto-scaling workers and
HTTP/3/Unix-socket remain follow-ups.

ADR-062 — Unix domain socket listener (--unix <path>)

justapi serve now accepts --unix <path> (precedes --addr), serving HTTP/1.1
over an AF_UNIX socket with the same middleware/handler pipeline as TCP. Works
with --workers N prefork via fd inheritance (parent binds once, workers recover
via listener_from_unix_fd).

Property Result
--unix /tmp/j.sock single-process "Listening on /tmp/j.sock (plain HTTP/1.1, unix)"; curl --unix-socket served
--unix /tmp/j.sock --workers 2 parent + 2 workers share the UDS fd; curl served
UDS + SIGTERM (parent) both workers drain + exit 0, tree reaped
TLS over UDS unsupported (clear error) — documented limitation

No request-path perf regression vs TCP (shared serve_connection). Unit test
test_unix_socket_serves_http added. Gates green.

ADR-063 — Route-lookup cache (Router::resolve)

A bounded, always-on memoization of route lookups. Static-route hits and
definitive NotFounds are cached (removing the matchit traversal + the
cross-method MethodNotAllowed scan on repeat hits/misses). Param routes and
MethodNotAllowed bypass the cache (fresh full match each request, no stale
data). Bounded FIFO eviction (default 1024 keys), Arc<Mutex<HashMap>>.

Property Result
Repeat GET on a static route served from cache after first match
Repeat miss (NotFound) memoized; no re-scan
Param route (/users/{id}) resolves fresh each time, params intact
--workers N per-worker router cache (no cross-process sharing needed)

No routing-semantics change (param/method-not-allowed/fallback identical).
Unit tests added. Gates green. Negligible fixed memory per key.

ADR-064 — XML request/response support (justapi_core::xml)

XML is now a first-class content type. Responses: xml_response / XmlResponse<T>
emit application/xml. Requests: an application/xml / text/xml body is
normalized to a serde_json::Value (xml_to_json) — nested elements → objects,
repeated siblings → arrays, attributes → @name, leaf text collapses to scalar —
so Python handlers receive uniform JSON. Content negotiation via negotiate /
respond lets handlers return JSON or XML from one Value. New dep: quick-xml.

Property Result
XmlResponse (typed) emits application/xml, correct element tree
xml_to_json nested + arrays {"user":{"id":1}}, {"root":{"item":["a","b"]}}
XML request body (Python path) converted to JSON; invalid XML → 400
negotiate / respond Accept: application/xml → XML response
Gates 274 core tests (8 new); clippy -D warnings; fmt clean

Limitation: attributes only as @name; namespaces/comments/DTD ignored.

ADR-065 — Worker auto-scaling (justapi serve --scale)

The prefork supervisor now auto-scales the worker fleet between --min-workers
and --max-workers based on normalized system load (1-min load avg /
parallelism via getloadavg). Pure, tested decide_scale controller; cooldown
prevents flapping; scale-down uses graceful SIGTERM and is excluded from
restart-on-death. No new dependency.

Property Result
--scale --workers 1 --max-workers 3 --scale-high 0.0 1 → 2 → 3 workers (log: "scaled up")
--workers 3 --min-workers 1 --scale-low 1.0 3 → 2 → 1 (log: "scaled down", graceful)
SIGTERM during scaling drains whole tree, all exit 0
--scale without bounds min=--workers, max=workers*2 (floored +1)

Unit tests: decide_scale (in-band/up/down/clamp/step) + next_free_slot.
Gates green. Worker isolation (fd-sharing) preserved at every size.


Real-life e-commerce API stress test — demo_shop/ (recorded 2026-07-18)

Built a complex, join-heavy online-shop API (demo_shop/, Olist SQLite
olist.sqlite ~112 MB: 99k orders, 33k products, 3k sellers, 100k reviews,
1M geo rows; 23 endpoints: catalog, orders aggregate, sellers, analytics,
search). Purpose: find framework problems the echo/hello micro-benchmarks
hide. It found several.

Path throughput (what a real app actually hits)

Path Throughput p50 latency Notes
No-DB app.run() + oha -c 64 37,171 req/s ~1 ms Rust HTTP + Python dict handler, real TCP
DB-backed /products, concurrency=1 ~250–330 req/s effective 2–4 ms framework handler+DB dispatch, post-ADR-068
DB-backed /products, oha -c 64 314 req/s 188 ms GIL-pool saturation at 64-way, no deadlock
wal=True connect on 112 MB file — 0.00 s was a 30s+ hang before ADR-068
Raw app.db.query (same queries) ~1–100 ms/query — DB itself was always fine

Framework defects surfaced — ROOT-CAUSED & FIXED (ADR-068)

  • D1 — app.run() "deadlocks" on a configured database — RETRACTED. The
    server always bound and served; the original probe hit a DB-backed route that
    deadlocked at the time (D4) and was misread as "server not up." No run()
    bind deadlock existed. See demo_shop/README.md Finding #3.
  • D2 — set_database(url, wal=True) hangs the pool connect forever — FIXED.
    after_connect ran PRAGMA journal_mode=WAL with conn.execute and no
    busy_timeout; concurrent pool warm-up blocked on the WAL exclusive lock
    forever. Fix: prepend PRAGMA busy_timeout=5000 and run pragmas via
    fetch_optional (justapi-core/src/db/pool.rs). wal=True now connects in
    0.00s on the 112 MB Olist file.
  • D3 — DB-backed handler ~2500× slower (900 ms/req) — FIXED. Root cause:
    connect_database() used a current-thread tokio runtime whose handle was
    block_on-ed from foreign threads (GIL pool / server thread), catastrophic
    serialization. Fix: build a multi-threaded runtime
    (Builder::new_multi_thread().worker_threads(4)) in
    crates/justapi-py/src/native/app.rs. p50 at concurrency=1 dropped 910 ms →
    2–4 ms (~250×).
  • D4 — concurrent async load deadlocks — FIXED. Same current-thread-runtime
    root cause as D3. Verified: oha -z 12s -c 64 served 3,706 concurrent requests
    (p50 188 ms, p99 367 ms, 0 deadlocks). 188 ms p50 at -c 64 is GIL-pool
    saturation across 64 Python handlers, inherent and DB-independent.

Conclusion

The networking core is solid (37k req/s no-DB). The default Python-handler +
SQLite path had two genuine bugs — D2 (WAL hang) and D4 (current-thread-runtime
deadlock, which also caused the D3 slowdown) — both fixed in justapi-core /
justapi-py under ADR-068. After the fix the DB-backed app serves real
concurrent traffic over TCP with single-digit-ms latency at concurrency=1. The
remaining headroom (188 ms p50 at -c 64) is GIL-pool serialization of Python
handlers, independent of the DB path.

Property Result
Routes registered 23 (all pass smoke via AsyncTestClient)
No-DB server throughput 37,171 req/s @ -c 64
DB-backed handler throughput ~250–330 req/s @ concurrency=1 (p50 2–4 ms)
DB-backed run() serves fine (D1 retracted)
DB-backed concurrency>1 314 req/s @ -c 64, no deadlock (D4 fixed)
wal=True connect 0.00 s (D2 fixed)