Skip to content

Commit 5169db3

Browse files
hyperpolymathclaude
andcommitted
perf(d-3): add explicit amortised-handshake Benchee scenario (mTLS N-over-keep-alive)
Closes the bracket scenarios 3 + 5 left loose: per-iteration cost is (handshake + N * request) / N which approximates the per-request cost the Phase E rollout sees once the handshake is amortised across a kept-alive pool. Adds: - bench/gateway_latency.exs: scenario 6 "mTLS amortised (test CA, N requests over kept-alive)" plus a dedicated kept-alive acceptor on port 19_879 running an echo loop (separate listener from scenario 5 so its close-on-handshake shape doesn't kill the kept-alive connection). N=16; payload is a tiny 8-byte ASCII frame. - bench/baseline.json: new scenario key with TODO placeholders; _schema_version bumped to 0.3.0-scaffold. - docs/perf-contract.md: scenarios table + targets table now list six scenarios; the previously deferred amortised follow-up is moved out of "Out of scope" into in-scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 301e0d3 commit 5169db3

3 files changed

Lines changed: 147 additions & 25 deletions

File tree

bench/baseline.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"_comment": "Phase D scaffold-placeholder baseline (standards#99). Numbers are TODO. Phase D-1 (PR #12) wired the Benchee harness + this file + the CI gate; Phase D-2 (PR #14) added a real in-process loopback backend so the 'exact route allow (proxy 200)' scenario measures real dial-and-read cost instead of port-1 connection-refused; Phase D-3 (this update) added two scenarios -- 'trust-header rewrite (Proxy.build_backend_headers)' isolating the Phase A contract-header construction cost, and 'mTLS handshake (test CA)' isolating per-handshake cost using the Phase B real-CA fixture. Phase D-4 collects real numbers via the `perf: rebaseline` ritual in docs/perf-contract.md and flips `_status` to 'active' to arm the CI gate. DO NOT trust these numbers; the perf-regression CI gate is intentionally non-blocking until that flip.",
3-
"_schema_version": "0.2.0-scaffold",
2+
"_comment": "Phase D scaffold-placeholder baseline (standards#99). Numbers are TODO. Phase D-1 (PR #12) wired the Benchee harness + this file + the CI gate; Phase D-2 (PR #14) added a real in-process loopback backend so the 'exact route allow (proxy 200)' scenario measures real dial-and-read cost instead of port-1 connection-refused; Phase D-3 (PR #22) added two scenarios -- 'trust-header rewrite (Proxy.build_backend_headers)' isolating the Phase A contract-header construction cost, and 'mTLS handshake (test CA)' isolating per-handshake cost using the Phase B real-CA fixture. Phase D-3 follow-up (this update) adds 'mTLS amortised (test CA, N requests over kept-alive)' which holds one connection open and does N=16 send/recv round-trips per iteration, closing the bracket that scenarios 3 + 5 left loose. Phase D-4 collects real numbers via the `perf: rebaseline` ritual in docs/perf-contract.md and flips `_status` to 'active' to arm the CI gate. DO NOT trust these numbers; the perf-regression CI gate is intentionally non-blocking until that flip.",
3+
"_schema_version": "0.3.0-scaffold",
44
"_generated_at": "TODO(Phase D-4): replace with ISO8601 timestamp from collection run",
55
"_generated_by": "TODO(Phase D-4): replace with `bench/gateway_latency.exs` git SHA + machine fingerprint",
66
"_status": "scaffold-placeholder",
@@ -36,10 +36,17 @@
3636
"p95_us": "TODO",
3737
"p99_us": "TODO",
3838
"ips": "TODO"
39+
},
40+
"mTLS amortised (test CA, N requests over kept-alive)": {
41+
"_comment_d3": "Phase D-3 follow-up scenario. One :ssl.connect/4 followed by N=16 send/recv round-trips on the open connection, then :ssl.close/1. Per-iteration cost is (handshake + N * request) / N, approximating the per-request cost the Phase E rollout sees once the handshake is amortised across a kept-alive pool. Closes the bracket scenarios 3 ('exact route allow (proxy 200)') and 5 ('mTLS handshake (test CA)') left loose. Uses a dedicated kept-alive acceptor on port 19_879 with an echo loop so the connection stays open across the N round-trips; payload is a tiny 8-byte frame so per-request cost is dominated by connection round-trip, not payload write/read.",
42+
"p50_us": "TODO",
43+
"p95_us": "TODO",
44+
"p99_us": "TODO",
45+
"ips": "TODO"
3946
}
4047
},
4148
"tolerance": {
42-
"_comment": "Multiplicative regression tolerance applied to each percentile. 1.20 = a PR is allowed to be up to 20% slower than baseline before the gate fails. Phase D-4 will revisit these once we have signal on intra-run variance. D-3 scenarios use the same tolerances pending D-4 calibration; the mTLS handshake scenario is likely the noisiest (TLS RNG draws + cert chain validation timing) and may need a looser p99 cap once we see the spread.",
49+
"_comment": "Multiplicative regression tolerance applied to each percentile. 1.20 = a PR is allowed to be up to 20% slower than baseline before the gate fails. Phase D-4 will revisit these once we have signal on intra-run variance. D-3 scenarios use the same tolerances pending D-4 calibration; the mTLS handshake scenario is likely the noisiest (TLS RNG draws + cert chain validation timing) and may need a looser p99 cap once we see the spread. The amortised scenario is expected to sit close to N * proxy-200 in per-iteration terms (since the handshake is paid once per 16 round-trips); its variance should be tighter than scenario 5's but looser than scenario 3's.",
4350
"p50_max_ratio": 1.20,
4451
"p95_max_ratio": 1.30,
4552
"p99_max_ratio": 1.50

bench/gateway_latency.exs

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@
6666
# client-internal cert against a raw
6767
# :ssl acceptor; isolates per-handshake
6868
# cost from the proxy hot-path
69+
# 6. mTLS amortised (test CA, N requests over kept-alive)
70+
# — D-3 follow-up — one :ssl.connect/4
71+
# followed by N tiny send/recv round-trips
72+
# on the open connection, then close.
73+
# Approximates the per-request cost the
74+
# Phase E rollout sees once the handshake
75+
# is amortised across a kept-alive pool.
76+
# Closes the bracket scenario 3 + scenario
77+
# 5 left loose.
6978
#
7079
# Each scenario is reported under p50 / p95 / p99 latency, matching the SLO
7180
# names in docs/perf-contract.md. Throughput (req/s) is reported as a
@@ -76,10 +85,7 @@
7685
# • Compare against a populated baseline.json (Phase D-4 collects baseline
7786
# numbers on a CI-equivalent target and flips `_status` to `active`)
7887
# • Run distributed (single-node only; estate concurrency-pool guard in #122)
79-
# • Capture amortised cost of N requests over one kept-alive TLS connection
80-
# as a dedicated scenario (the per-handshake cost in scenario 5 plus the
81-
# proxy-200 cost in scenario 3 already bracket it; if D-4 numbers show
82-
# the bracket is loose, a follow-up adds an explicit amortised scenario)
88+
# (Scenario 6 in D-3 closes the previously deferred amortised follow-up.)
8389
#
8490
# Those are sequenced post-D-3 tasks under the single-lane HCG channel
8591
# (standards#91).
@@ -284,6 +290,86 @@ acceptor_pid =
284290
accept_loop.(accept_loop)
285291
end)
286292

293+
# ── mTLS amortised acceptor fixture (Phase D-3, scenario 6) ──────────────────
294+
#
295+
# Second :ssl listener dedicated to the amortised scenario. Each accepted
296+
# connection is held open and the server echoes back every frame the
297+
# client sends; the connection terminates when the client closes its
298+
# side. The amortised bench scenario opens ONE connection, does N
299+
# send/recv round-trips, then closes, so this acceptor sees one
300+
# handshake + N echo cycles + one close per Benchee iteration.
301+
#
302+
# Separate listener (port 19_879) instead of reusing 19_878: scenario 5's
303+
# acceptor is shaped to close immediately after handshake, which would
304+
# kill the kept-alive connection scenario 6 needs. Keeping the two
305+
# fixtures independent also means D-4 can run them back-to-back without
306+
# the scenario-5 acceptor's close-on-handshake racing scenario 6's
307+
# send/recv loop.
308+
#
309+
# N (kept_alive_request_count below): chosen as 16 — a small bounded
310+
# constant in the same order of magnitude as typical HTTP/1.1 keep-alive
311+
# pool reuse counts before pool rotation, large enough that
312+
# (handshake + N * request) / N visibly differs from the per-handshake
313+
# cost in scenario 5 (so the bracket scenarios 5 + 3 left loose is
314+
# visibly tightened), small enough that one iteration completes well
315+
# within the Benchee per-iteration budget.
316+
#
317+
# Payload (kept_alive_payload below): a tiny 8-byte ASCII frame. The
318+
# point is to measure per-request *connection* cost (round-trip on an
319+
# established TLS session), not bulk-data throughput; the payload is
320+
# kept deliberately small so socket-write timings don't dominate.
321+
kept_alive_request_count = 16
322+
kept_alive_payload = "bench-d3"
323+
324+
{:ok, kept_alive_listen_socket} = :ssl.listen(19_879, tls_listen_opts)
325+
326+
kept_alive_acceptor_pid =
327+
spawn_link(fn ->
328+
accept_loop = fn loop ->
329+
case :ssl.transport_accept(kept_alive_listen_socket, 5_000) do
330+
{:ok, transport_socket} ->
331+
case :ssl.handshake(transport_socket, 5_000) do
332+
{:ok, tls_socket} ->
333+
# Echo loop: read whatever the client sends, send it back,
334+
# continue until the client closes. The bench client does
335+
# exactly N send/recv pairs and then closes, so this loop
336+
# exits cleanly on {:error, :closed} once :ssl.close/1 has
337+
# been called on the client side.
338+
echo_loop = fn echo ->
339+
case :ssl.recv(tls_socket, byte_size(kept_alive_payload), 5_000) do
340+
{:ok, data} ->
341+
case :ssl.send(tls_socket, data) do
342+
:ok -> echo.(echo)
343+
{:error, _reason} -> :ssl.close(tls_socket)
344+
end
345+
346+
{:error, :closed} ->
347+
:ok
348+
349+
{:error, _reason} ->
350+
:ssl.close(tls_socket)
351+
end
352+
end
353+
354+
echo_loop.(echo_loop)
355+
356+
{:error, _reason} ->
357+
:ok
358+
end
359+
360+
loop.(loop)
361+
362+
{:error, :timeout} ->
363+
loop.(loop)
364+
365+
{:error, :closed} ->
366+
:ok
367+
end
368+
end
369+
370+
accept_loop.(accept_loop)
371+
end)
372+
287373
# ── Benchee scenarios ────────────────────────────────────────────────────────
288374

289375
try do
@@ -304,6 +390,22 @@ try do
304390
end,
305391
"mTLS handshake (test CA)" => fn ->
306392
{:ok, sock} = :ssl.connect(~c"127.0.0.1", tls_port, tls_client_opts, 5_000)
393+
:ssl.close(sock)
394+
end,
395+
"mTLS amortised (test CA, N requests over kept-alive)" => fn ->
396+
# ONE handshake, then N tiny send/recv round-trips on the open
397+
# connection, then close. Per-iteration cost is
398+
# (handshake + N * request) / N which approximates the per-request
399+
# cost in a kept-alive setting. N is the module-level constant
400+
# `kept_alive_request_count` (see the amortised acceptor fixture
401+
# above for why 16 was chosen).
402+
{:ok, sock} = :ssl.connect(~c"127.0.0.1", 19_879, tls_client_opts, 5_000)
403+
404+
Enum.each(1..kept_alive_request_count, fn _ ->
405+
:ok = :ssl.send(sock, kept_alive_payload)
406+
{:ok, _echoed} = :ssl.recv(sock, byte_size(kept_alive_payload), 5_000)
407+
end)
408+
307409
:ssl.close(sock)
308410
end
309411
},
@@ -333,4 +435,9 @@ after
333435
Process.unlink(acceptor_pid)
334436
:ssl.close(listen_socket)
335437
Process.exit(acceptor_pid, :shutdown)
438+
439+
# Same teardown shape for the amortised acceptor (Phase D-3 scenario 6).
440+
Process.unlink(kept_alive_acceptor_pid)
441+
:ssl.close(kept_alive_listen_socket)
442+
Process.exit(kept_alive_acceptor_pid, :shutdown)
336443
end

docs/perf-contract.md

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
> single-lane HCG channel (standards#91) collects the baseline and fills
1010
> them in. The CI gate is non-blocking until that point. Phase D-3 (this
1111
> revision) is the last harness-shape change before D-4 collection: it
12-
> adds the `trust-header rewrite` and `mTLS handshake` scenarios so the
13-
> baseline attributes those costs independently of the proxy-200 path.
12+
> adds the `trust-header rewrite`, `mTLS handshake`, and `mTLS amortised`
13+
> scenarios so the baseline attributes header-rewrite cost, per-handshake
14+
> cost, and per-request kept-alive cost independently of the proxy-200
15+
> path.
1416
1517
Tracks: `Refs hyperpolymath/standards#99` (Phase D of `standards#91`).
1618

@@ -24,12 +26,13 @@ backend (added in Phase D-2), since the production gateway always pays
2426
that cost on an allow path; the loopback strips out real-network RTT and
2527
real-backend processing time so what remains is the gateway-attributable
2628
proxy overhead. The `mTLS handshake` scenario (added in Phase D-3)
27-
covers per-handshake transport cost in isolation. Real production
28-
backend latency, real-network RTT, and amortised mTLS cost across a
29-
kept-alive connection remain out of scope — the latter is bracketed by
30-
the `proxy 200` + `mTLS handshake` scenarios pending D-4 signal.
29+
covers per-handshake transport cost in isolation, and the
30+
`mTLS amortised` scenario (added in the Phase D-3 follow-up) covers the
31+
per-request cost when N=16 requests share one kept-alive TLS connection,
32+
closing the bracket that `proxy 200` + `mTLS handshake` left loose. Real
33+
production backend latency and real-network RTT remain out of scope.
3134

32-
The five named scenarios match the five Benchee scenarios in
35+
The six named scenarios match the six Benchee scenarios in
3336
`bench/gateway_latency.exs`:
3437

3538
| Scenario | What it measures |
@@ -39,6 +42,7 @@ The five named scenarios match the five Benchee scenarios in
3942
| `exact route allow (proxy 200)` | Allow path that proxies to an in-process loopback backend returning 200. Captures the real dial-and-read cost the gateway pays in production. |
4043
| `trust-header rewrite (Proxy.build_backend_headers)` | Direct call to the `Proxy.__benchmark_build_backend_headers__/1` benchmark seam on a pre-built conn (`assigns[:trust_level] = :authenticated`, `assigns[:request_id]` set). Isolates the Phase A contract-header construction (X-Trust-Level / X-Request-ID / X-Forwarded-*) from policy lookup and network I/O. |
4144
| `mTLS handshake (test CA)` | `:ssl.connect/4` against a raw `:ssl` acceptor + immediate close. Test CA chain is generated in-memory at bench startup via `:public_key.pkix_test_data/1` — the committed Phase B fixture ships only `*.crt` files (matching `*.key` is gitignored), so the chain is rebuilt with the same `verify_peer` + test-CA shape rather than reusing the on-disk material. Per-handshake cost (cert chain validation + key exchange) in isolation; no HTTP pipeline. Bounds the connection-spike SLO for Phase E rollout. |
45+
| `mTLS amortised (test CA, N requests over kept-alive)` | One `:ssl.connect/4` followed by N=16 tiny send/recv round-trips on the open connection, then `:ssl.close/1`. Uses a dedicated kept-alive acceptor (separate listener from the handshake-only scenario) with an echo loop, so the connection stays open across the N round-trips. Per-iteration cost is `(handshake + N * request) / N`, which approximates the per-request cost the Phase E rollout sees once the handshake is amortised across a kept-alive pool. Closes the bracket scenarios 3 + 5 left loose. |
4246

4347
## Published latency SLOs
4448

@@ -57,6 +61,7 @@ Reported per scenario, per CI run:
5761
| `exact route allow (proxy 200)` | TODO | TODO | TODO |
5862
| `trust-header rewrite (Proxy.build_backend_headers)` | TODO | TODO | TODO |
5963
| `mTLS handshake (test CA)` | TODO | TODO | TODO |
64+
| `mTLS amortised (test CA, N requests over kept-alive)` | TODO | TODO | TODO |
6065

6166
Units: microseconds (µs). Hardware reference: `ubuntu-latest` GitHub
6267
runners — the CI environment IS the published reference, deliberately
@@ -103,15 +108,9 @@ The scaffold deliberately does NOT yet include:
103108
`perf: rebaseline` ritual described above produces these on a
104109
CI-equivalent target and flips `bench/baseline.json` `_status` to
105110
`active`).
106-
- A dedicated "mTLS amortised" scenario covering the per-request cost
107-
when N requests share one kept-alive TLS connection. The combined
108-
bracket of `mTLS handshake (test CA)` (one-off cost) and
109-
`exact route allow (proxy 200)` (per-request cost with the handshake
110-
already paid) bounds it; if D-4 numbers show that bracket is too
111-
loose to be useful, a follow-up adds the explicit amortised scenario.
112111
- Dashboard publication of historical numbers (Phase E — `standards#100`).
113112

114-
D-3 (this iteration) added two new scenarios to
113+
D-3 (this iteration) added three new scenarios to
115114
`bench/gateway_latency.exs`:
116115

117116
- `trust-header rewrite (Proxy.build_backend_headers)` — direct call
@@ -126,11 +125,20 @@ D-3 (this iteration) added two new scenarios to
126125
same `verify_peer` + test-CA shape rather than reusing the on-disk
127126
material. Each iteration is one fresh handshake closed immediately;
128127
isolates per-handshake cost from the proxy hot-path.
129-
130-
`bench/baseline.json` gains the two new scenario keys (both `TODO`,
128+
- `mTLS amortised (test CA, N requests over kept-alive)` — dedicated
129+
kept-alive acceptor (separate listener) running an echo loop. Each
130+
Benchee iteration opens one connection, does N=16 tiny send/recv
131+
round-trips on the held-open connection, and then closes. Per-iteration
132+
cost is `(handshake + N * request) / N`, which approximates the
133+
per-request cost in a kept-alive setting. This was previously deferred
134+
as out-of-scope and bracketed by `mTLS handshake` + `proxy 200`; the
135+
follow-up adds it as an explicit scenario so D-4 baseline collection
136+
captures it directly rather than via the bracket.
137+
138+
`bench/baseline.json` gains the three new scenario keys (all `TODO`,
131139
`_status` remains `scaffold-placeholder`) and bumps `_schema_version`
132-
to `0.2.0-scaffold` to signal the shape change. D-4 will land real
133-
numbers across all five scenarios in a single dedicated
140+
to `0.3.0-scaffold` to signal the shape change. D-4 will land real
141+
numbers across all six scenarios in a single dedicated
134142
`perf: rebaseline` PR.
135143

136144
D-2 (the previous iteration) landed the in-process `Plug.Cowboy`

0 commit comments

Comments
 (0)