Proposal 042: sortie as the soak's load driver — k6 retired¶
Design record. This proposal is published as written, at its stated status. Later proposals may supersede parts of it, and implementation details drift. It documents the reasoning at a point in time, not the current behaviour of the system — for that, see the docs.
Note (2026-10-09): the soak harness this proposal describes has left this repository. It is now an external soak harness, maintained outside this repository. Every
e2e/soak/...path and every//e2e/soak:...target below names a file as it was here when this was written; none of them exists in this tree any more. The text is kept as the record of the design.
Status: Implemented. sortie is the soak's load driver and k6 is retired; the
harness is now maintained outside this repository, so runs later than the ones
below are not recorded here.
The harness was built and proven on kind 2026-10-06 (#1339) against
sortie b71b37e, and moved to sortie 94cf103 on 2026-10-07, which fixed every
defect the first version had to work around ("What sortie 94cf103 retired").
One new sortie defect was found on the way: it waits without limit for an engine
whose node has gone silent ("Risks", 5).
Phase 1, the short e2e on the reference cluster, ran twice on 2026-10-07 ("Verified on
the reference cluster"): the driver works there, and it needs a client queue, which the
plan now carries by default ("The client queue").
The harness then moved to sortie 9fcbb81 (2026-10-07, "What sortie 9fcbb81
changed"): that defect is fixed, a request still in flight when a run ends is
counted, a stage that was not run is marked as such, the report carries
latency and timestamps, and each execution's result is also written as it
finishes. With 9fcbb81 the short e2e passed on the reference cluster (run 4, 2026-10-08,
8 of 8) and the first 8-hour soak ran the same day: the sortie gate passed
with 9,180,000 of 9,180,000 requests answered 2xx under 35 rollouts
("Verified on the reference cluster").
The harness then moved to sortie f0750ec (2026-10-08, "What sortie f0750ec
changed"): a stage refused at the execution cap is marked by a field, and a
driver that wakes past a backend's deadline takes the answer that is waiting.
Proven on kind only.
The harness then moved to sortie 96e6bfb (2026-10-08, "What sortie 96e6bfb
changed"): nothing the harness reads or passes changed; the engine has a fix
for a data race between executions that start together, and an image no longer
gets a new digest from a commit that did not change it.
Proven on kind; neither f0750ec nor 96e6bfb has run on the reference cluster yet.
Author: Bruno Palermo
Date: 2026-10-06
Related: #1323 (the kickoff lived outside the repository and had two
defects; closed by this), #1093 / #1320 (nodes are CPU-tight at proxy handoffs,
which is what the engine's CPU cost collides with), #846 / #887 (the k6
per-class and per-target failure breakdown this has to replace), #1108 (the UDS
share), #1009 (the benign k6 DC artefact; one row with its signature was seen under
sortie, unexplained),
1086 (first-use failures at loader start), #1242 / #1265 (the restart¶
watchdog), 013 (the external prober, still the authority for PASS/FAIL).
Problem¶
The soak's load has been k6 since the first run: a DaemonSet of grafana/k6
pods, one per worker node, each at 60 iterations per second for 8h30m against
six mesh services and two UDS-delivered ones (e2e/soak/k6-mesh-soak.js,
k6-runner.yaml). On 2026-10-06 the decision was taken that nightly-1006 was
the last k6-driven soak and that validation from now on runs on
sortie: declarative plans over Nighthawk,
whose engine is the same Envoy the mesh runs.
Three things about the k6 setup are worth not carrying over:
- Its verdict exists only in a pod's stdout.
http_req_failedand the per-target table are printed once, when k6 exits; the DaemonSet then restarts the container, so the summary is inlogs --previous, and a teardown at the wrong minute loses it. The harness has lost a soak's summary to that more than once. - Its per-target breakdown is hand-built. k6's summary aggregates across tags, so the script declares 297 threshold submetrics to make "which target, which class" visible at all, and a classifier in the script maps k6's error numbering onto classes (it was wrong from #846 to #887).
- Launching it is not in the repository. The kickoff, the nightly wrapper
and the saver are scripts on the operator's machine (
<run-dir>/<date>/…). #1323 found two defects in them on the last run: the restart watchdog started at T0+30 min instead of at T0, and the pre-flight's readiness check used an awk back-reference, which awk does not have, so it printedNOT READYfor every healthy pod.
What changes, in one paragraph¶
One sortie engine (nighthawk_service) per worker node, as a mesh-managed
DaemonSet in aether-test; one sortie Job whose plan finds the engines through
a headless Service and drives each of them at 60 rps, split over the same eight
targets as eight concurrent executions with their own rate limiters, counters
and histograms. The verdict is a JSON report written to a PersistentVolume and
copied to the run directory before anything is torn down; sortie-gate.sh
turns it into one PASS/FAIL line per target. Everything that launches a run is
e2e/soak/run.sh. The prober remains the authority for PASS/FAIL; nothing about
the churn schedule, the watchdog or the access-log gates changes.
Topology¶
aether-test
┌──────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Job soak-sortie-<hash> │ gRPC │ DaemonSet soak-sortie-engine (per node) │
│ 1 pod, NOT mesh-managed ├───────▶│ pod label aether.io/managed: "true" │
│ resolves the headless │ :8443 │ 8 executions = 8 worker threads │
│ Service ONCE, at start │ pod IP │ HTTP/1.1 ──▶ node proxy :18081 ──▶ mesh │
│ writes report ▶ PVC │ │ statsd (UDP) ──▶ o11y/otel-scraper:8125 │
└──────────────────────────────┘ └─────────────────────────────────────────┘
Service soak-sortie-engine-nodes (headless): one A record per ready engine
- The engine is the mesh client. It opens the connections, so it carries
exactly what the k6 runner pod carried: the
aether.io/managed: "true"label (ndots injection, the CNI capture divert, the:53DNAT to mesh DNS) and theconfig.aether.io/upstreamspre-warm list, unchanged. The load enters through the node's own proxy, by qualified mesh name, on:18081. - The sortie pod is not a mesh client. It has no
aether.io/managedlabel. It resolvessoak-sortie-engine-nodes.aether-test.svc.cluster.localthrough cluster DNS and talks gRPC to each engine's pod IP on:8443; inbound to a managed pod is not captured, and the engines' replies are the reply direction of a tracked flow, which the capture table exempts. - Identity. The chart gives both pods the ServiceAccount
soak-sortie, so the load's source identity isaether-test/soak-sortie, notdefaultas under k6. Access-log queries that select the loader byclient_sachange accordingly (and can now tell the loader from everything else that runs asdefault). Requests also carryUser-Agent: aether-soak-sortie. - PriorityClass
aether-soak-loader, reused, for the reason it exists: a DaemonSet pod cannot move, and without it the loader is the scheduler's victim when a surgingaether-proxypod needs room. The sortie pod carries it too (the chart's top-levelpriorityClassName): it can move, but it is the run's single point of failure and a run cannot. The chart only references the class;run.shapplies it and reads it back, before the engines and again before the Job, becausekubectl delete -f k6-runner.yamldeletes it and a pod naming a class that does not exist is refused at admission. - Two-step install. A
dns:pool is resolved once, when the run starts, and takes the first non-empty answer.run.shinstalls the engines alone (job.enabled=false), waits for the DaemonSet and for the headless Service to list every engine, and only then upgrades the release with the plan. - Pinned by digest and verified, both images and the chart. All three are
signed by sortie's publish workflow (cosign, keyless, the signature an OCI
referrer), and the chart defaults both image references to digests. They were
verified by hand with the repository's pinned cosign (v3.1.2): the chart, and
the index and both per-arch manifests of each image, for 96e6bfb (and before
it f0750ec, 9fcbb81 and 94cf103, each bound to its commit; the f0750ec chart
digest bound to the 9fcbb81 commit is refused, which is the check doing its
work). Up to f0750ec every digest changed with every sortie commit, because
the commit was a label in each image's config. Since sortie 6d801c0 the
chart's and the driver's still do and the engine's changes only when the
engine does; a digest is signed once, by the commit that first published it,
so the 96e6bfb engine is bound to 6a0a866 ("What sortie 96e6bfb changed").
run.shrepeats the check in its pre-flight on every run, against what the release actually renders (--verifymakes a missing cosign an abort).
Mapping: the load shape¶
k6 (k6-mesh-soak.js) |
sortie (sortie-plan.sh) |
|
|---|---|---|
| arrival | constant-arrival-rate, 60/s per runner |
constant-rate, rate: 60, per_backend: true, open_loop: true |
| duration | 8h30m | 30600s (profile soak); 900s (profile e2e) |
| protocol | HTTP/1.1 cleartext to :18081 (k6 speaks h2 only over TLS) |
protocol: http1, same URLs |
| targets | one random draw per request: 95 % uniform over six mesh targets, 5 % uniform over two UDS targets | eight concurrent executions, each paced on its own |
| concurrency | 60–180 VUs, one keep-alive connection each | one worker thread per target, which waits for its next request (WAIT); connections as the rate needs them (a handful) |
| request timeout | 60 s (k6 default) | none per request; a request still unanswered 30 s after the run ends is counted (http_inflight_lost, since sortie 1178261): see "What is lost" |
| DNS | a lookup per new connection | once per execution, at start |
| verdict | http_req_failed, printed at exit |
thresholds, per target, in a JSON report |
What http_req_failed == 0 and the failure classes become¶
The k6 gate was "every runner's http_req_failed is 0, or every failure is
attributed", read from eight class counters and a by-target table. Under sortie
each target is its own execution, and the gate is the zero-failure set: these
counters == 0, per target, on every node.
k6 class (aether_fail_*) |
sortie counter | notes |
|---|---|---|
http_4xx, http_5xx |
benchmark.http_4xx, benchmark.http_5xx |
a complete response with that status |
conn |
benchmark.pool_connection_failure (+ pool_failure_local_… / pool_failure_remote_connection_failure) |
never got a connection |
timeout |
benchmark.pool_failure_timeout |
connect timeout only; there is no per-request response timeout |
| — (k6 failed it at 60 s) | benchmark.http_inflight_lost |
sent, or queued for a connection, and with no outcome when the run was over and the request timeout (30 s, the engine's default) had passed after it. Not a reset, not a status, in no other counter (sortie 1178261; before it such a request was counted nowhere). The gate words it in flight at the end: N request(s) with no outcome |
proto, and any mid-response cut |
benchmark.stream_resets (+ _before_headers, _incomplete_body, _<reason>) |
the reason counters name Envoy's StreamResetReason |
| — | benchmark.pool_overflow |
the client refused its own request, which was never sent: the driver saturated beyond its client queue ("The client queue"), never the mesh's failure. The gate words it driver saturated: N request(s) not sent. No k6 equivalent (k6 reports dropped_iterations) |
dns |
— | no per-request class: a name that does not resolve fails the whole execution with an error, before any request |
tls |
— | the soak drives cleartext to the local proxy |
other |
— | nothing is unclassified: a request is counted under a status or under a reset or pool counter |
The counters overlap — a reset is in stream_resets, in one phase counter
and in one reason counter — so the gate lists them and never adds them up. A
counter that never incremented is absent from the report and reads zero; the
pool_overflow control for that is real (it was seen non-zero on kind, under
the CPU limit the engine used to need, and on the reference cluster with no client queue).
An execution goes the distance. Nighthawk carries four default failure
predicates — benchmark.http_4xx, http_5xx, pool_connection_failure and
stream_resets, each with a limit of 0 — and by itself ends an execution the
moment one is exceeded. Against sortie b71b37e that was fatal twice over: the
first 503 of the first roll would have stopped that target's load on that node
for the remaining hours, and the report then carried an error and no counters,
so it could not say which class had failed. The first version of the plan
lifted all four predicates by hand (nighthawk_template.failure_predicates,
limits of 10¹²). Since 94cf103 sortie turns them off itself and an execution
lasts its duration, so the plan no longer mentions them. Re-run on kind without
the workaround (one target's Deployment scaled to zero for 90 s of a 240 s run):
the execution ran 240.06 s on both nodes and the gate read FAIL uds-cr-echo
rate=3.7/6rps http_2xx=888/1440 … http_5xx=552 [sortie-worker=276
sortie-worker2=276], with the seven other targets PASS.
Four things the gate adds that k6's rate<0.01 never had:
- A count floor per target, per node. Each backend's
http_2xxmust be within 99 % and 101 % of what the plan asked of it: the target's share × the configured duration. Zero failures with nothing sent is otherwise a pass. The plan can carry only one floor for the whole scenario (the smallest share's, 3 rps × nodes) and sortie judges it against the pool's total;sortie-gate.shapplies each target's own floor to each node from the report's per-backendresults, so one slow node is named instead of being averaged away. Until 9fcbb81 this was a rate over the backend's own elapsed time. That time is fixed when the run stops and can read a hair under the duration (899,999 ms of 900,000 on the reference cluster), which only flatters a rate — and a backend that stopped at 96 % of the run, at exactly its share, had a perfect rate. A count against the plan has neither problem, and it is a range because a late-wokenWAITworker ends a run one request short — sortie's own README reports 999 of 1,000 in about one short run in four on a busy machine and asks for "a range with some slack, not an equality". The second reference-cluster run read 40,497–40,500 of 40,500 per 9 rps target with every failure class at zero ("Verified on the reference cluster"): that, or requests still in flight when the run stopped, which 94cf103 counted nowhere — its reports cannot say which. 1 % is 81 requests of a 9 rps target's 15 minutes, 2,754 of its 8h30m. - A stage that was not run fails. sortie marks an execution it never
attempted
"not_run": true(a stage after one that an engine refused at its execution cap); it has no counters, so every failure class reads zero. The gate fails it by name, counts it in the verdict, and cross-checks the sortie pod's own log forSKIPlines and aK not runsummary. This plan is a single stage, so the case is a guard, not an expectation. - The pool is checked. The gate requires every execution to have been dispatched to exactly the number of engines that were ready at launch. A node missing from the pool fails the run instead of shrinking it.
- A lost backend fails its node's share, not the run. Since 94cf103 an
engine that goes away mid-run is named in the execution's
backend_errorsand the others are reported whole. The gate fails every target for the lost node, names it, and still judges each survivor on its own counters. The plan's own floor cannot do this: with one of five nodes gone, a 9 rps target's pool still runs at 36 rps, far above "99 % of 3 rps × 5".
sortie-gate.sh prints, per target, PASS/FAIL, the achieved and planned
rate, the 2xx total against the planned count, the worst node's p50 and p99,
and for a failure the class, the count and the node (the report names
backends by pod IP; run.sh records the IP → node map at launch). --per-node
adds a line per target and node. That replaces the k6 "by target" table and adds
the node, which k6 could only give per runner log. It reads results (with
each backend's statistics for the latency), totals, backend_errors,
not_run and started_at / ended_at, and refuses a report without results
(a sortie older than 94cf103) or without started_at (older than 9fcbb81,
which did not count requests in flight) rather than reading counters that were
never counted as zero.
A FAIL line is what k6's non-zero http_req_failed was: the list of what has
to be attributed from the access logs and the roll brackets, not by itself a
failed soak. The 2026-09-30 run had 147 k6 failures, 141 of them before T0.
Every README gate that reads k6 output¶
| gate / step (README) | reads from k6 today | under sortie |
|---|---|---|
| Run step 3: "all five runners report 60.00 iters/s" before churn | runner logs | the sortie pod Running after the settle time, its progress lines in kubectl logs, one a minute (http_2xx per target and node), and the live per-node …benchmark_http_2xx series if statsd is on; run.sh refuses to start churn if the pod is not running |
Run step 5: teardown only after k6 exits; summary in logs --previous |
pod log | sortie-save.sh (armed at load start) copies the report and the log; sortie-teardown.sh refuses without its SAVED marker. The Job pod does not restart, so nothing is in --previous |
The restart gate: "the watchdog stops at T0+8h because k6-soak-loader restarts once by design" |
— | a finished Job pod has no restart; the 8 h window no longer has a trap at its end. The watchdog also now sees an engine OOM (it did, on kind) |
The UDS gates, item 1: "the --- by target table must show no uds-echo / uds-cr-echo row" |
k6 summary | sortie-gate.sh: PASS uds-echo, PASS uds-cr-echo; a FAIL row must match an access-log row, as before. Control counts double (see "Rate shares") |
The UDS gates, control query: client_sa default ≈ 216,000 per authority |
access logs | client_sa soak-sortie ≈ 432,000 per authority |
Benign DC (#1009): k6 lines must sit in a source-node proxy roll bracket; excluded from the k6 reconciliation |
access logs + k6 | expected to disappear for the loader: see below. If DC + 200 + full bytes rows appear for client_sa soak-sortie, that is a finding, not a baseline. One did, in the second reference-cluster run ("Verified on the reference cluster") |
| New-SA step text: "visible only because the k6 loaders happened to start … 58 s before T0" (#1086) | history | the engines are a new identity on every node at install; run.sh waits SOAK_ENGINE_SETTLE (30 s) after they are Ready before the first request |
| Gate 3 (twin count = observed pairs): "k6 loaders + prober + new-SA steps" | Prometheus | same query; the loader's pairs are now …@aether-test/soak-sortie |
| QUIC per-request cost gate (#1021): "at the soak's load shape", 300 rps | Pyroscope / Prometheus | the fleet rate is unchanged (300 rps), the per-target mix is not: re-baseline h3/h2 on the first sortie soak rather than comparing against 1.18× across drivers |
| Gotcha 6, "k6 needs 1Gi" | — | the engine's own numbers, below |
Files: AETHER_FAIL sample lines with timestamps, the only instrument that placed a burst against a roll |
k6 log | the verbatim per-failure line is lost. Placement in time comes from three coarser instruments: the progress lines in sortie.log (every 60 s, cumulative counters and latency mean/max per target and node), statsd (10 s resolution, per target and node — see "Telemetry"), and the access logs, which carry the user agent |
What is lost¶
- Per-request random target mixing. k6 drew a target per iteration, so the targets' requests interleaved randomly and any one second could hold any mix. sortie paces each target on its own limiter: a fixed 9 or 3 rps each, every second. Bursts across targets never coincide by chance, and never fail to.
- The k6
DCclass — and that is a gain. k6 closed the connection after a full body while the h3 upstream FIN was still in flight, which the source proxy logged asDC(#1009). Envoy's client does not treat a disconnect after a complete response as a failure, and it does not close first. (Expected, and nearly so: the second reference-cluster run logged one such row in 269,992, still not a failure to the engine. "Verified on the reference cluster".) - Per-request DNS. A target is resolved once per execution, by the engine,
at the start. Mesh DNS is exercised 8 times per node per run by the loader,
not continuously. The prober's
mesh_dnstier (which resolves on every probe) is and stays the DNS SLI; the loader was never the authority for it. - A per-request response timeout. A request that never gets an answer
waits until the run ends and for the request timeout (30 s) after it. k6
failed it at 60 s. Since sortie 1178261 it is then counted, in
http_inflight_lost, and the gate fails the target on it; until then it was in no counter at all and a single hung request was invisible to the loader. What is still lost is when: it is one number at the end of the run, not a failure 60 s after the request. The count floor catches a target that stops answering wholesale, and the prober's 2 s budget remains the instrument for the moment it happens. - The verbatim, timestamped failure sample (
AETHER_FAIL). What is left is a one-minute timeline per target and node (the progress lines), not a line per failure. defaultas the loader's identity, with every query that assumed it.
What is gained¶
- Per-target rate limiters. One slow target cannot starve the others of their slots, which under k6's shared VU pool it could.
- Per-target latency histograms and counters, native, with no declared key space to maintain and nothing to classify by hand.
- Reset reasons from the same codec the mesh runs (
connection_termination,remote_reset,protocol_error, …) and a before-headers / mid-body split. - A verdict that is a file, per target and per node.
- A loader that does not restart inside or after the window.
Rate shares¶
k6: 60 iterations/s per runner, 95 % uniform over six mesh targets (15.8 % = 9.5 rps each) and 5 % over two UDS targets (2.5 % = 1.5 rps each).
sortie needs a whole number of requests per second per target, each a multiple
of concurrency, and rounds a share that is not whole — sortie-plan.sh
refuses one instead, so the shares always add up to the rate. With six equal
mesh shares m and two equal UDS shares u, 6m + 2u = 60 has three
whole-number solutions near the k6 mix: (10, 0), (9, 3) and (8, 6).
Proposed: 9 rps × 6 mesh + 3 rps × 2 UDS = 60 rps per node (weights 3 : 1).
| per node | k6 | sortie | change |
|---|---|---|---|
| each mesh target | 9.5 rps (15.8 %) | 9 rps (15 %) | −5 % |
| each UDS target | 1.5 rps (2.5 %) | 3 rps (5 %) | ×2 |
| total | 60 | 60 | — |
What that changes, on five nodes over the graded 8 h:
- UDS gate control counts double: ~432,000 clean 200s per UDS authority
from the loader (was ~216,000);
uds-client's ~28,800 is unchanged. The gate itself is still "0 non-benign lines"; only the size of the control moves. - Mesh target counts: 1,296,000 per target (was ~1,368,000).
- Fleet rate: 300 rps, unchanged, so fleet CPU stays comparable — except that 15 rps per node moved from TCP delivery to UDS delivery.
- The uds-csi roll steps see twice the traffic through each roll: a delivery failure there is twice as likely to be caught client-side.
- A 5 % UDS share exactly would need 1.5 rps per target, which neither sortie
nor
concurrency: 1can express. 3 rps is the smallest whole share that keeps the six mesh targets equal.
CPU and memory¶
The engine's CPU cost was the part of the cut-over that was not free. Since sortie 94cf103 it nearly is: about 0.11 core per node, a CPU request, no CPU limit.
An idle Nighthawk worker thread is not idle unless told to wait. Between
requests the sequencer spins (SPIN, Nighthawk's and sortie's default), polls a
25 µs timer (POLL), or sleeps 50 µs at a time (SLEEP). None of those blocks
until the next request is due, so the cost is per worker thread and independent
of the rate: a 3 rps target costs what a 9 rps one does. Eight targets are
eight worker threads per node. sortie 94cf103 adds a fourth strategy, WAIT,
which blocks until the rate limiter says the next request is due and wakes at
least every 5 ms to see whether the run should end. That wake-up is the floor
that is left: about 14 millicores per worker here. In 94cf103 WAIT was
opt-in, through nighthawk_template, and the plan asked for it; since sortie
7f338df it is sortie's default and the plan no longer says anything
(sortie-plan.sh --idle-strategy still can, for an experiment).
Measured on kind (two worker nodes, 20-core workstation, the engine
container's cgroup cpu.stat and memory.*, the plan's 60 rps per node over
eight executions, 2 minutes each unless noted; nr_throttled is the number of
CFS periods in which the container was throttled):
| idle strategy, limit | CPU per engine | nr_throttled |
2xx sent, of planned | latency p50 / p99 (worst node) |
|---|---|---|---|---|
| no execution | 0.004 cores | — | — | — |
SPIN (default), none — b71b37e |
8.0 cores | — | — | — |
POLL, none — b71b37e |
2.85 cores | — | — | — |
SLEEP, none |
1.68–1.81 cores | 0 | 100.0 % | 2.2–2.7 ms / 2.8–3.3 ms |
SLEEP, 400m, max_pending_requests: 16 — what shipped before |
0.41–0.44 (at the limit) | every period (249–272 of the 255–273 in a 25 s window; 51–105 s of thread time throttled in each) | 99.3 % | 2.5–2.7 ms / 21–78 ms |
WAIT, 200m |
0.10 cores | 38–43 at start, then 5 more (1.7 s) in one 25 s window on one node | 98.1–98.3 % | 2.0–2.5 ms / 2.5–3.0 ms |
WAIT, 400m |
0.10 cores | 17–20, all at start | 99.3 % | 2.0–2.7 ms / 2.4–3.5 ms |
WAIT, none — what ships |
0.11–0.12 cores | 0 | 100.0 % | 2.0–2.8 ms / 2.5–3.6 ms |
the same over 15 min, with --progress 60s |
0.125–0.14 cores (0.115–0.13 between snapshots) | 0 | 100.0 % (108,000 of 108,000) | 1.8–2.4 ms / 2.2–2.9 ms |
sortie's own measurement is 26 millicores for one worker at 60 rps and 11 per idle worker; eight workers at 3–9 rps each landing on 110–130 agrees with it.
The limit is removed, not kept as a guard rail. Three readings decide it:
WAITmakes the engine cheap enough not to need one: 0.11 core against the 1.7 the limit was there to contain, and against k6's own 100m request.- A limit is not free even when the steady state fits under it four times over.
At 400m the engine was throttled in 17–20 periods at the start of every
run, while eight executions and their threads come up, and each execution
then ran about 0.8 s short: 2,144–2,145 of 2,160 requests per mesh target in
two minutes, where the unlimited engine sent 2,160. At 200m it was throttled
mid-run as well. "
nr_throttledstays 0" is the condition under which a limit could have stayed, and it does not hold. - A throttled load generator reports its own scheduling delay as the target's
latency, and refuses its own requests (
pool_overflow) when it falls behind. Both read as the mesh. An engine that is unexpectedly expensive (a plan rendered withSPIN) is better found in the node's CPU, where it is looked for, than in the report.
With the limit gone, max_pending_requests went too — and came back after the
first reference-cluster run, for a reason that has nothing to do with CPU limits. It had
existed so that a throttled worker could queue the requests that came due while
it was off CPU; on kind an unthrottled engine never needed it. On the reference cluster it
does: see "The client queue", next.
The request is 150m: the measured 0.125–0.14 core with the progress snapshots in it, and a little room. The reference cluster's workers have 3.95 allocatable cores and sit at 1.3–1.65 in use before any load. Read there on 2026-10-07 (two 15-minute runs): 127–135m and 302–321 MiB per engine pod, so the request stands.
The client queue¶
The plan carries max_pending_requests, sized as the largest share per
worker × a stall budget of 2 s: 18 for this plan. connections is left at
the engine's default.
What the two fields are, read from the engine source at sortie 94cf103
(engine/source/client/options_impl.h, process_bootstrap.cc,
benchmark_client_impl.cc) rather than assumed, and unchanged at 9fcbb81 (the
two defaults in options_impl.h were read again):
| plan field | Nighthawk | default | what it caps |
|---|---|---|---|
connections |
--connections |
100 per worker thread (HTTP/1) | concurrent connections: the pool's max_connections circuit breaker. sortie passes it only when the plan sets it |
max_pending_requests |
--max-pending-requests |
0, "no client side queuing" | requests waiting for a connection: the pool's max_pending_requests circuit breaker, which the engine sets to 1 for a value of 0 |
A worker thread is one target on one engine. A request that falls due and finds
no idle connection makes the pool open another one (up to 100) and waits,
pending, until it is up. So a slow answer costs a connection and nothing
else: at 9 rps every request would have to hang for 11 s to reach the
connection cap, and raising connections would change nothing. What overflows
is the pending queue: with the default, one request may wait for a connection
and a second that falls due meanwhile is refused — pool_overflow, a request
that is never sent. (In open loop the engine's own in-flight backpressure is
off; the circuit breaker is the only thing that refuses.) That takes several
requests due at once with no connection ready for them: the node not completing
new connections, or not running the worker thread, for longer than the gap
between two requests (111 ms at 9 rps), after which the backlog arrives
together. Which of the two it was on the reference cluster has not been established.
On kind (20 idle cores) that never happened in any run. On the reference cluster, without
a queue, it happened 6 times in 270,000 requests (run 1 below), on two nodes,
in executions whose progress lines show a ~1 s response-time maximum; with
max_pending_requests: 16 it did not happen in a run with an agent roll and a
proxy roll in it (run 2). k6 absorbed the same moments without anyone
noticing: 60 preallocated VUs per runner, 180 at most. An open-loop driver
needs somewhere to put a request whose connection is not ready yet; that is
what a client queue is, and removing it was reading a kind result as a general
one.
Why 18 and not 16. A worker at R rps has at most R × S requests due
after S seconds in which none could start, so a queue of R × S absorbs a
stall of S. 16 at 9 rps is a budget of 1.8 s, which is what run 2 proved
enough; the longest response the mesh logged in the two runs was 1.455 s. A
fixed 16 would silently become a 0.9 s budget at 18 rps per target, so the
plan derives it instead: ceil(largest share ÷ concurrency × stall budget),
budget 2 s — the first whole second above everything the mesh logged. sortie
carries the field on the scenario, not on the target, so one number serves all
eight targets and is written for the largest share; the 3 rps UDS targets get
the same 18 and therefore a 6 s budget. That is uneven and harmless: the queue
does not turn a refused request into a passed one, it lets a late one be sent.
The bound is a worst case. A stall in which answers are slow but new
connections still come up uses none of the queue, which is why run 1 has
executions with 2.1–2.3 s response-time maxima and no overflow. What has not
been run is 18 itself (run 2 used 16) and anything longer than 15 minutes.
pool_overflow stays in the zero-failure set, with a narrower meaning. It
is now: the driver was saturated beyond the stall budget on that node — a
stall longer than 2 s, at 9 rps. It is a failure of the run's validity for
that target on that node (requests planned and not sent), attributable to the
node and the moment, and it is not a mesh error: the request never reached the
proxy and has no access-log row. sortie-gate.sh says so in words — driver
saturated: N request(s) not sent [<node>=N] — apart from the failures list
of the mesh's classes, so that a grader does not count it as a data-plane
failure. --max-pending N / SOAK_SORTIE_MAX_PENDING still fix the number, 0
turns the queue off for an experiment, and --stall-budget /
SOAK_SORTIE_STALL_BUDGET change the budget.
Pacing under WAIT (the 15-minute SHORT profile, run twice, per target
and node, achieved against planned): 8,100 of 8,100 for every 9 rps target on
both nodes, 2,700 of 2,700 for both 3 rps targets on both nodes — 108,000 of
108,000 requests in each run, none short, none refused. The throttled engine of
the first version sent 16,182–16,186 of 16,200 per mesh target over the same 15
minutes (8,091–8,094 per node).
Latency under WAIT (the same two runs, per target and node): p50
1.7–2.7 ms, p99 2.1–3.3 ms. The unthrottled SLEEP engine, which polls every 50 µs, reads
the same (p50 2.2–2.7 ms, p99 2.8–3.3 ms), so blocking until the next request
adds nothing visible to the measurement. The first version's 15-minute run,
SLEEP under the 400m limit, read p50 ≈ 3 ms and p99 ≈ 90 ms for a mesh target
(from the statsd histogram: 16 % of its requests took longer than 10 ms, and
its mean was 9.4 ms against 2.0–2.1 ms now) — the CFS period, not the mesh.
So sortie's latency is now usable as a soft signal, and the report carries
it: since sortie 1b3d404 each backend's entry in results has its
statistics (benchmark_http_client.latency_2xx with count, mean, min, max,
p50, p90, p99 and p99.9, in nanoseconds). sortie-gate.sh prints the worst
node's p50, p99 and max per target from there and judges nothing by them.
Until then the plan carried two thresholds, latency_2xx.p50 < 60s and
latency_2xx.p99 < 60s, only because a statistic could reach the report no
other way than as a threshold's actual; they could not fail on a mesh that
answers at all, and they are removed, not replaced. Latency is not a gate
until the reference cluster has shown what the numbers look like there, under rolls, on
nodes that are busy: on kind p99 is flat at 2–4 ms with nothing else running,
and the first reference-cluster runs read 115–207 ms. When there is a number to defend,
the place for it is a real threshold in the plan (latency_2xx.p99 < …),
which sortie judges per backend.
Memory is the engine's latency histograms, about 37 MiB per worker per execution. On kind, same plan: 300 MiB with eight executions running, flat for 15 minutes (299.9 → 301.8 MiB, and 300.0 → 301.9 in the second run); a peak of 597–603 MiB for the instant the final reports are assembled; 270–290 MiB still resident in the idle engine afterwards. sortie's own figures for eight executions are 421 MiB running, 722 MiB peak and 340–400 MiB held, on an unoptimized build. The values file requests 384 MiB with a 1 GiB limit (k6: 256 MiB / 1 GiB): a limit below the peak kills the engine as the run ends, and 512 MiB killed both engines of the first version sooner still, at their first progress snapshot.
--progress is passed, at 60 s. Against b71b37e a progress snapshot copied
every histogram: the first one took a running engine from 300 to 550–585 MiB
and the second to ~810 MiB, so the first version passed none. Since 94cf103 a
snapshot carries summaries only, and the 15-minute runs above ran with
--progress 30s and 60s at the same 300 MiB as a run without. What a
snapshot still costs is CPU: sampled in 10 s windows, an engine reads
115–128 millicores between snapshots and 173–186 in a window that holds one,
so one snapshot of eight executions is a burst of about 0.6 CPU-second — +19
millicores averaged at 30 s (131–133 measured over that run), +10 at 60 s.
Pacing and latency did not notice either. A line per target and node every
minute is the run's
liveness in the pod log and, saved, a one-minute timeline of every counter with
the latency mean and max — enough to place a failure against a 12-minute
roll schedule, and the statsd series resolve 10 s for anything finer.
Whether memory grows over 8 h is unmeasured (the histograms are fixed-size, which argues no; nothing has run longer than 15 minutes here).
Telemetry¶
sortie has no OTLP sink (Envoy's aborts the engine on its first flush there);
the plan's stats block makes each engine flush its Envoy stats store to a
statsd server over UDP, and the address must be an IP literal.
The reference cluster already has the receiver. o11y/otel-scraper (GitOps:
clusters/<cluster>/otel-scraper/values.yaml) exposes 8125/UDP on a ClusterIP Service
(read on 2026-10-07), with a statsd receiver (10 s aggregation,
monotonic counters, |ms timers as explicit-bucket histograms), a
deltatocumulative processor and job="statsd". It was added for Nighthawk
in clj-grpc-soak. run.sh --statsd auto (the default) resolves that
Service's cluster IP and UDP port at launch and templates it into the plan; if
the Service or the port is absent the run goes ahead without live metrics and
says so. --statsd off disables it; --statsd IP:PORT overrides. No GitOps
change is needed to receive.
Names. On the wire, since sortie 94cf103:
sortie.<scenario>.<target>.<backend>.cluster.<worker>.benchmark.http_2xx counter
sortie.<scenario>.<target>.<backend>.cluster.<worker>.benchmark_http_client.latency_2xx timer, ms
and as they arrive in Prometheus (the path was verified on kind with 94cf103 against a collector with the reference cluster's receiver and processor settings, read from the GitOps values; with 9fcbb81 the names were read off the wire only):
sortie_mesh_<target>_<backend>_cluster_0_benchmark_http_2xx_total
sortie_mesh_<target>_<backend>_cluster_0_benchmark_pool_overflow_total (exists once non-zero)
sortie_mesh_<target>_<backend>_cluster_0_benchmark_http_client_latency_2xx_{bucket,sum,count}
sortie_mesh_<target>_<backend>_cluster_0_upstream_rq_total … and Envoy's other cluster counters
sortie_mesh_svc_1_node_c_cluster_0_benchmark_http_2xx_total (an example)
<scenario> is mesh; <target> has - as _; cluster_0 is worker 0, the
only one per target. <backend> is the engine's node, by name, lowercased
and reduced to [a-z0-9_] (node-c → node_c). That is
sortie 0fc5746: the plan's stats block says backend: name, sortie sends a
placeholder where the name goes, and each engine puts in the name it was
started with (--backend-name), which the chart takes from the node
(engine.backendNameFrom: node, set in sortie-values.yaml). Up to 94cf103
<backend> was the engine's pod IP (10_244_3_17), the map from it to a node
was engines.tsv, and every install of the engines wrote a new set of names.
The receiver attaches no labels and does not expose the sender, so the node is
still in the metric name and nowhere else — but it is a name that means
something and does not change. Two consequences of sortie not knowing the
names: an engine started without one refuses the execution (so the two
settings are pinned together by the harness test), and nothing checks that two
nodes' names stay distinct once reduced to [a-z0-9_] (the reference cluster's
node-a … node-e do). The report is unchanged: it names a backend
by pod IP and port, and the gate maps that through engines.tsv.
The per-node series sum to the report. With sortie b71b37e the names carried no backend, every engine wrote the same series, and two engines read as one engine's worth (8,085–8,101 where the report said 16,182–16,186). With 94cf103 each engine has its own. On kind, at the end of the 15-minute run:
| target | series | sum over the two backends | report totals |
|---|---|---|---|
| each of the six 9 rps targets | 2 (8,100 + 8,100) | 16,200 | 16,200 |
| each of the two 3 rps targets | 2 (2,700 + 2,700) | 5,400 | 5,400 |
and each series equals that backend's results[].counters in the report. So
the live view can now place a failure class on a node. That equality held
because it was a first run on series nobody had written before; an absolute
value on these series is not, in general, one run's count. The engine sends
no totals: every flush carries what each counter gained since the last one
(read off the wire on kind with f0750ec: 40|c 45|c 45|c … 44|c 5|c for a
9 rps target on one node, which add up to the report's 540; the next run sends
40|c 45|c … again under the same name), and the collector's
deltatocumulative adds them up. Since the names stopped changing with every
install (0fc5746), a series the collector still holds carries on from the
previous run's total; it starts from zero only if the collector had expired it
or was restarted, and that can happen inside a run. On the reference cluster, 2026-10-08,
sortie_mesh_svc_1_node_c_cluster_0_benchmark_http_2xx_total: 7,739
after run 4 (the report: 8,097); from zero again when the soak started 21
minutes later; 241,808 at the soak's end where the report has 275,400,
with one reset in between; increase() over the soak's window: 274,864. So:
rate() and increase() across runs, never a total read as a run's count
(sortie's README says the same since f0750ec). The grade comes from the JSON
report: the series are UDP and a collector's memory, and the report is a
file. Nothing in sortie-gate.sh or in the README's grading recipe reads them.
What it costs: 33 metric families per target and backend (two of them
histograms of 16 buckets), 528 for the kind run, and so about 1,320 families,
some 2,800 series, on five nodes — once. While the backend was a pod IP every
install of the engines wrote that many new names, which the previous run
stopped updating; named by node, a run writes the names the last one wrote.
(Counted with 94cf103; http_inflight_lost adds a family when it is non-zero.)
Dashboard. aether-k6 (read 2026-10-06) is built on k6_* series keyed by
testrun_name — k6_http_reqs_total, k6_http_req_failed_total,
k6_http_req_duration_milliseconds_bucket, k6_vus, by endpoint and
status — which k6-operator test runs export. The soak runner exports none of
them (it runs without an output sink on purpose), so the dashboard has never
shown a soak. Its replacement for the soak is a small one over the
sortie_mesh_* series above: 2xx rate per target and per node
(…_benchmark_http_2xx_total, the target and the backend taken out of the name
with label_replace), one stat per zero-failure class
(increase({__name__=~"sortie_mesh_.*_benchmark_(http_[45]xx|http_inflight_lost|stream_resets.*|pool_.*)_total"}[$__range]),
the selector of the README's "Live view",
with the dashboard's range set to the run, PLUS the series born inside the
range, which increase() reads as 0: for each class the stat is
(sum(increase(m[$__range]) and m offset $__range) or vector(0)) + (sum(m unless m offset $__range) or vector(0))
(each sum defaults to zero, or an empty arm would make the whole stat "no data"):
increase() only for a series that already existed at the start of the
range, the current value only for one born inside it, so a first failure
cannot look clean and a new series is not counted twice), and p95 per target from the latency
histogram, which is the mesh's latency now that the engine is not throttled.
Every panel and any alert on these series is a rate() or an increase(),
never the series' value (#1420): named by node, a failure series outlives the
run that made it, so its bare value is not zero on a later clean run, and a
series born inside the window reads as 0 under increase(), which is why
the stat above and any alert carry the "present now, absent before" arm
(m unless m offset <window>, each arm with or vector(0)) next to increase(); the same rule is in the
runbook for the ext_authz counters and under "Live view" in
e2e/soak/README.md. The dashboards and rules that existed on
2026-10-08 were checked for this: aether-k6 puts every k6_* counter under
rate() and reads only the k6_vus gauges bare, and no dashboard or rule reads
a sortie_* series yet. It
is a GitOps change (sidecar ConfigMaps in the platform's GitOps repository), made in phase 2,
once the first reference-cluster run has shown what the series look like there. Retiring
aether-k6 and k6-operator is phase 4, and only if nothing else still uses
them.
Report durability¶
- The sortie pod writes the JSON report to
/var/run/sortie/<run tag>.jsonon PVCsortie-soak-reports(run.shcreates it;openebs-hostpathon the reference cluster, which has no default class, sorun.shtakes the cluster's only class, or--storage-class). One small file per run; the PVC is kept across runs. sortie-save.shis armed, detached, as soon as the Job exists and beforerun.shfirst looks at its pod (#1387: armed after that look, it was never armed for a Job that had already ended, and such a run's report is the one thing that says why it ended). When the Job finishes it copies into the run directory: the report (through a short-lived reader pod that prints the file —kubectl execis denied on the reference cluster), the sortie pod's log (the progress lines and the readable summary), the Job and pod objects, every engine's log, and the engine pod list. It writesSAVEDlast, only if the report parsed.sortie-teardown.shrefuses to uninstall withoutSAVED(--forceoverrides), and keeps the PVC unless--purge.- The Job has no TTL, so the pod and its log stay until teardown.
- The results stream (sortie 9fcbb81): beside the report, on the same PVC,
sortie appends one line of JSON per execution as it finishes
(
/var/run/sortie/<run tag>.jsonl;--results-stream, the chart'sreport.stream), synced to disk line by line. It is what exists if the sortie pod dies before the report is written — risk 4's eight lost hours, in the case where the executions had finished. The saver copies it out (results.jsonl) whether or not there is a report, andsortie-gate.sh --streamgrades it. sortie appends to that file and never truncates it, sorun.shnames it by run tag (a file of the run's own) and the gate fails a label that occurs twice; a run that died mid-write leaves a last line that does not parse, which the saver counts and the gate skips — the execution it was about is then missing, which is a FAIL. With this plan every execution ends at the same moment, so the stream is written seconds before the report; it protects that window, and any later plan with stages. - When the run was. Every execution in the report has
started_atandended_at(sortie's clock) and every backend its ownstarted_at(the engine's clock, when its first worker started). The saver writes them astimes.tsvand the gate prints oneWINDOWline, so a failure can be set beside theROLLEDlines ofchurn.logwithout reconstructing the start fromT_LOAD.
What is in the repository now (#1323)¶
| file | role |
|---|---|
e2e/soak/run.sh |
the one kickoff: pre-flight (with the provenance check) → PriorityClass → engines → load → churn (soak) → watchdog at T0 → saver |
e2e/soak/sortie-values.yaml |
chart values: DaemonSet, mesh label, PriorityClass (engines and the sortie pod), resources, --progress, digests |
e2e/soak/sortie-targets.txt, sortie-plan.sh |
the eight targets and weights; the plan template for both profiles and the share arithmetic |
e2e/soak/sortie-save.sh, sortie-gate.sh, sortie-teardown.sh |
save before teardown; PASS/FAIL per target with per-node attribution; uninstall |
e2e/soak/pods-not-ready.awk |
the numeric readiness check |
e2e/soak/harness_test.sh + testdata/{sortie,preflight}/ |
offline tests of the gate, the plan, the pins and the readiness check (//e2e/soak:harness_test) |
The two #1323 defects: the watchdog is started by run.sh right after the
churn driver (soak) or the load (e2e), never after the kickoff returns; the
readiness check compares ready and total as numbers, and the test suite keeps
the old expression as a red reading (it flags all eight healthy pods of the
fixture).
Deploying aether is deliberately not in run.sh. The nightly wrapper's
"wait for main to publish, deploy, check surge/debug/limits" half stays a
separately authorized helm upgrade.
What sortie 94cf103 retired¶
The first version of this harness (#1339) was built against sortie b71b37e and worked around seven things. Each was reported to the sortie session; 94cf103 (main, 2026-10-07) fixed all of them, and the harness dropped each workaround after re-running the case it had been written for.
| sortie b71b37e | the harness's workaround | sortie 94cf103 | the harness now |
|---|---|---|---|
| an execution ended at its first 4xx / 5xx / reset / connection failure, and then reported no counters | nighthawk_template.failure_predicates, four limits of 10¹² |
sortie turns the default predicates off; a stopped execution still reports its counters | no failure_predicates in the plan. Deliberate failure re-run: 240 s of 240 s on both nodes, http_5xx=552, only that target fails |
a worker thread never blocked: 8.0 / 2.85 / 1.7 cores per engine (SPIN / POLL / SLEEP) |
SLEEP, a 400m CPU limit, and max_pending_requests: 16 so the throttled engine queued instead of refusing |
the WAIT idle strategy (opt-in, through the template) |
WAIT, a 150m request, no CPU limit: 0.11–0.14 core, every request sent, p99 2–4 ms instead of 20–100 ms. The pending queue went with the limit and came back after the first reference-cluster run as a sized client queue ("The client queue"): open-loop load needs one on nodes that stall, limit or no limit |
a --progress snapshot copied every histogram (300 → 550–810 MiB, OOM at 512 MiB) |
no --progress |
snapshots carry summaries; memory is the same with and without | --progress 60s: 300 MiB flat |
| statsd names carried no backend: every engine wrote one series | "the live series are one engine's worth; grade from the report" | <backend> (the engine's address, sanitised) in the prefix |
per-node series that sum to the report; the caveat is gone, the IP → node map is documented |
| the JSON report held thresholds and non-zero failure counters only | a counter:benchmark.http_2xx > 0 threshold as a carrier for the 2xx total |
per execution: results (per backend), totals, backend_errors |
the carrier is dropped; sortie-gate.sh reads the three, judges rate and failures per node, and refuses an old-format report |
one dead engine failed every execution with an error, and the other nodes' counters were lost with it |
none possible; the run was void | a lost backend is named in backend_errors, the others are reported whole |
the gate fails each target for the lost node, names it, and still judges the survivors (kind: one engine pod deleted at T+60 s) |
| the chart was unsigned and named the engine by tag | both images pinned by digest in the values file, the chart by digest in run.sh |
the chart is signed and defaults both images to digests | all three still pinned here, and verified on every run by run.sh's pre-flight |
Progress lines also carry the execution label now (mesh/svc-1 10.0.0.11:8443
…), which is what makes them usable as the per-target, per-node timeline the
k6 AETHER_FAIL lines used to be the only source of.
What sortie 9fcbb81 changed¶
The second bump, 94cf103 → 9fcbb81 (eight commits on sortie main, all of
2026-10-07). Every name below was read in the sortie source at that commit
(internal/report/report.go, internal/run/run.go, internal/compile/,
api/sortie/plan/v1/plan.proto, the chart) before the harness used it.
What the harness dropped, because sortie now does it:
| the harness did | why | sortie commit | the harness now |
|---|---|---|---|
nighthawk_template.sequencer_idle_strategy: WAIT in every plan |
WAIT was opt-in and the engine's default costs a core per worker |
7f338df: WAIT is sortie's default; a strategy named in the template is left alone |
nothing is templated. --idle-strategy / SOAK_SORTIE_IDLE_STRATEGY still write the template, for an experiment |
two thresholds that could not fail, latency_2xx.p50 < 60s and latency_2xx.p99 < 60s (and sortie-plan.sh --no-latency to drop them) |
the report held no statistic; a threshold's actual was the only way in |
1b3d404: results[].statistics |
both removed, the flag with them; the gate reads the statistics and prints p50, p99 and max. No latency threshold replaces them yet |
statsd series named by pod IP, with engines.tsv as the only map to a node, and ~2,800 new series per install of the engines |
sortie knew a backend only by its address | 0fc5746: stats.backend: name and the chart's engine.backendNameFrom |
the plan asks for backend: name, the values file for backendNameFrom: node: the series carry the node's name and are the same on every run |
the saver's SORTIE_OVERDUE line at two minutes, the only notice that sortie was waiting for a silent node without limit; the gate's ran off plan as the only thing that failed a thawed one |
sortie had no deadline on a backend | 76f699b: a backend that has not answered duration + timeout + drain + 2 min after dispatch is given up on and reported; one whose own account runs far past the plan is failed, its counters kept |
SORTIE_OVERDUE moves to five minutes and means sortie itself is stuck. The gate's own 5 % check stays: it is tighter than sortie's two minutes |
What stays, and why:
max_pending_requests. The engine's default is still 0 ("no client side queuing") and sortie still passes the field only when the plan sets it (options_impl.h,compile.goat 9fcbb81). Nothing better to default to; the sized queue stays.- No CPU limit, a 150m request. These were never workarounds for sortie; the chart sets no resources and the values file's are the harness's.
engine.maxConcurrentExecutions: 16. It is the chart's own default and was at 94cf103 too, so it is redundant — and kept, becausesortie-plan.shrefuses a targets file longer than that number and the two should be read in one place.
What the harness gained:
| sortie 9fcbb81 | commit | the harness |
|---|---|---|
an HTTP run waits for its in-flight requests, up to the request timeout, before it reads the counters; what is still open then is benchmark.http_inflight_lost (not a reset, not a default failure predicate). The engine's elapsed time is fixed at stop |
1178261 | counter:benchmark.http_inflight_lost == 0 in the plan; in the gate's zero-failure set, worded apart (in flight at the end: N request(s) with no outcome); the count is judged as a 99–101 % range against the configured duration |
a start refused at an engine's execution cap (RESOURCE_EXHAUSTED) stops the whole stage on every backend; later stages are SKIP in the text and "not_run": true in the JSON; the summary reads N/M executions passed, K not run |
1374999 | the gate fails a not_run execution by name, counts it (not_run=K), reads the pod's log for SKIP and K not run as a second witness, and words a refused stage REFUSED AT THE EXECUTION CAP instead of calling the refusing engines lost |
started_at / ended_at per execution, results[].started_at per backend |
9fcbb81 | times.tsv in the run directory, a WINDOW line from the gate; a report without started_at is refused as old |
a PASS/FAIL/SKIP line on stderr per finished execution |
9fcbb81 | in sortie.log, with or without --progress |
sortie run --results-stream FILE, the chart's report.stream: one JSON line per finished execution, appended, never truncated |
9fcbb81 | run.sh sets it to a file of the run's own; the saver copies it out; sortie-gate.sh --stream grades it, skipping lines that do not parse and failing a label listed twice ("Report durability") |
Not used: the TCP work of 83d7d47 (reconnect with backoff,
tcp.max_messages_per_connection, benchmark.tcp_reconnects /
tcp_connections_rotated / tcp_connections_opened / tcp_connect_failures /
tcp_unavailable / tcp_inflight_lost / tcp_echo_mismatch,
benchmark_tcp.connect_latency). The mp-dialer and udp-dialer legs stay
as they are until sortie can do what they do. What is still missing there, by
sortie's own account: an upstream idle-timeout option, telling a remote close
from a local one, UDP socket rotation, and per-target overrides in a scenario
that mixes schemes — the last is what would let a raw-TCP target sit beside the
eight HTTP ones in the one mesh scenario. A separate TCP scenario against
mixed-svc's raw port is now straightforward to write (an exact-echo target,
expect_echo: true, a reconnect that survives a roll, rotation to keep
connection setup in the measurement) and is left as a follow-up: scenarios run
one after another, not side by side, so it would be a second run, not a ninth
target.
What sortie f0750ec changed¶
The third bump, 9fcbb81 → f0750ec (one commit on sortie main, 2026-10-08,
answering findings of the previous bump). It touches the driver only:
internal/nh, internal/report, internal/run and the README. Each item
below was read in the sortie source at that commit before the harness used it.
The pins, and a digest that had to be looked up. Chart
sha256:b1f0a6d5…, driver sha256:3d9a9b67…, engine sha256:d0fbc860…, all
resolved from the registry and verified with the pinned cosign (v3.1.2): the
chart, and the index and both per-arch manifests of each image, bound to the
commit. The engine "is unchanged from 9fcbb81", and its digest is new all the
same (9fcbb81's index was sha256:99d5058c…). Both are true: on each
architecture the two images have the same 22 layers, digest for digest, and
configs that differ in one label, org.opencontainers.image.revision, which
is the commit. A new config is a new manifest and a new index. So every digest
moves with every sortie commit, a bump always changes all three pins, and "the
engine did not change" has to be read off the layers. What is pinned is what
the f0750ec chart names; the 9fcbb81 engine index does not verify against the
f0750ec commit.
| sortie f0750ec | the harness |
|---|---|
an execution of a stage refused at an engine's execution cap has "refused": "execution_cap" in the report and in the results stream; so does each backend_errors entry that is an engine which refused. The stages after it keep "not_run": true (and no refused) |
sortie-gate.sh knows a refusal by the field. An entry of backend_errors without it is a lost backend, also inside a refused stage, which the text match could not tell. The error text is still read for one shape only: an execution with no refused field at all, which is a 9fcbb81 report, and those are still graded with this gate (run 4 and the first soak are 9fcbb81 reports) |
| a backend's answer that is already there when the driver notices the deadline has passed, or arrives within 10 s of that, is taken as the result. The deadline is wall time, and a driver that was itself stopped can wake past it with every result waiting; 9fcbb81 then picked between "answered" and "overdue" at random | nothing to change. It is what the frozen-driver run of the 9fcbb81 evidence showed (the healthy node's engine reported silent on four of eight executions); re-run below |
| the README says what a receiver shows across runs on series named by backend name: the engine sends per-flush deltas, so a kept series carries on from the last run | "Telemetry" and the README's "Live view" now say it, with what the reference cluster's Prometheus showed. Nothing in the harness read a total as a count |
Not in f0750ec, and still done by the harness: run.sh used to abort on a
Job that had already ended before it armed the saver (the 9fcbb81 evidence
below saved that run by hand). The saver is now armed first and the abort
waits for it (#1387).
What sortie 96e6bfb changed¶
The fourth bump, f0750ec → 96e6bfb (three commits on sortie main, all
2026-10-08: 6d801c0, 6a0a866, 96e6bfb). Read in the sortie source: the diff
between the two commits touches no file under internal/, api/, charts/,
main.go or go.mod. So no flag, no field of the JSON report or of the
results stream, no exit code and no key of the chart's values changed: the
values the harness sets mean what they meant. The packaged chart, pulled by its
two digests, differs in three generated lines: its version, and the defaults
of the two image references (which the harness overrides by digest). The harness's scripts and fixtures are unchanged by this
bump; only the pins and what the documents say about them moved.
| sortie | what it is | the harness |
|---|---|---|
| 6d801c0 | no image carries the commit any more (org.opencontainers.image.revision is gone from both images, and so is the stamping). An image whose content did not change keeps its digest. The publish workflow verifies a digest before it signs and signs only what is not signed yet |
the README and sortie-values.yaml said "all three digests change with every sortie commit". That was true up to f0750ec and is corrected to what the registry shows (below) |
| 6a0a866 | the engine: two executions that start at once each construct Envoy's options, which writes a process-wide delimiter in the argument parser (TCLAP); the construction is now serialized. Found by sortie's ThreadSanitizer run. The rest of the commit is tests and the sanitizer job | nothing to change, and nothing to remove: the harness never worked around it. It is the harness's own case, eight executions started together on every engine, so the path ran on every run so far. Every writer stored the same character (sortie's commit message), which would make the race harmless in practice; that is sortie's reading, not something a run here can show |
| 96e6bfb | an engine test only (a progress snapshot may go out without statistics on a slow sanitizer host) | nothing. It is the commit that shows the first row working: its engine index is 6a0a866's |
The digests, measured. From the registry, each commit's
dev-<commit> tag for the two images and the chart's version tag, with every
per-arch manifest and config read:
| sortie commit | chart | driver index | engine index | engine layers (amd64 / arm64) | revision label |
|---|---|---|---|---|---|
| f0750ec | sha256:b1f0a6d5… |
sha256:3d9a9b67… |
sha256:d0fbc860… |
22 / 22 | yes |
| 6d801c0 | sha256:446caa71… |
sha256:d1ff3f59… |
sha256:94bde035… |
the same 22 / 22, digest for digest | no |
| 6a0a866 | sha256:a4dbd82d… |
sha256:f4ababe0… |
sha256:65392801… |
22 / 22, a different set | no |
| 96e6bfb | sha256:b30a4266… |
sha256:490dfbd6… |
sha256:65392801… |
as 6a0a866, and the same two per-arch manifests | no |
So the chart and the driver have a digest per commit (the chart's version is the commit; the driver's binary reports its own version) and the engine has a digest per engine. One commit shows it, 6a0a866 → 96e6bfb; 6d801c0 is the counter-example that is not one, since removing the label was a change of the image's config. At a bump all three are still taken from the new chart.
The signatures, and what binding to a commit now means. With the pinned
cosign (v3.1.2), signer …/bpalermo/sortie/.github/workflows/publish.yml@refs/heads/main,
issuer https://token.actions.githubusercontent.com, on the chart and on the
index and both per-arch manifests of each image, seven references:
| check | chart, driver ×3 | engine ×3 |
|---|---|---|
the signer alone (what run.sh checks) |
verified, one signature each | verified, one signature each |
--certificate-github-workflow-sha 96e6bfb… |
verified | refused: expected GithubWorkflowSHA to be "96e6bfb…", got "6a0a866…" |
--certificate-github-workflow-sha 6a0a866… |
refused (got "96e6bfb…") |
verified |
--certificate-github-workflow-sha f0750ec… |
refused | refused |
A digest is signed once, by the commit that first published it, so the engine
of this pin is bound to the commit that last changed it and not to the pin.
The earlier bumps recorded "bound to the commit" for all three; from here that
holds for the chart and the driver only. run.sh never bound to a commit: its
pre-flight verified the 96e6bfb chart and both images with no change
(verified=cosign-v3.1.2). The pinned cosign prints no commit for a signature
it accepts ("optional": {}), so the commit that signed a digest is read off
the refusal.
//e2e/soak:harness_test already held run.sh and sortie-values.yaml to
one commit. It now holds the README to it too: the sentence that states the
pin, and the chart digest in the cosign command a reader would copy (each seen
red with the previous pin's text).
Evidence on kind (2026-10-08, sortie 96e6bfb)¶
The same cluster shape, aether install (from the tree this bump was made on),
stand-in targets and UDP listener as for f0750ec below; every run through
run.sh e2e --verify (verified=cosign-v3.1.2, chart sha256:b30a4266…).
Run directory: <run-dir>/1008-sortie-96e6bfb-kind/.
| run | result |
|---|---|
| 5 min, the committed values (the f0750ec run, like for like) | 2,700 of 2,700 per 9 rps target on each node, 900 of 900 per 3 rps target: 36,000 of 36,000; every failure class 0; each backend's elapsed time 300,000–300,004 ms; p50 1.7–2.5 ms, p99 2.3–3.5 ms; VERDICT PASS targets=8 passed=8 failed=0 not_run=0 backends=2 lost_backends=0; watchdog verdict=PASS new_restarts=0; SORTIE_SAVED … not_run=0 … stream_executions=8, the stream's eight lines the report's eight executions, object for object |
| the e2e profile as it is, 15 min, nothing overridden | 8,100 of 8,100 per 9 rps target on each node, 2,700 of 2,700 per 3 rps target: 108,000 of 108,000; every failure class 0; each backend's elapsed time 900,001–900,002 ms; p50 1.9–2.6 ms, p99 2.5–3.5 ms; VERDICT PASS … 8 of 8; watchdog verdict=PASS new_restarts=0 over 30 samples; stream and report identical |
the engines capped below the plan (engine.maxConcurrentExecutions: 4, eight targets) |
as with f0750ec: the Job failed at once (window 50 ms), saver armed before the abort, the abort 46 s later with SORTIE_SAVED … job=failed executions=8 … stream_executions=8. All eight executions carry "refused": "execution_cap", every backend_errors entry is marked, and the report has the same set of fields as the committed fixture kind-capped.json (every path of the two documents compared). Gate: eight REFUSED AT THE EXECUTION CAP, lost_backends=0 |
What differs from the f0750ec runs: nothing the harness reads. The 5-minute report has the same keys at every level, the sortie pod's log the same 201 lines (column padding aside), the Job the same arguments, each engine's log the same 698 lines with no warning or error, and the counts are the same to the request.
The engine, a new binary at this bump (risk 3), read on each node from its
pod's cgroup: 0.099 core per engine over three minutes of the 5-minute run
and 0.107 over ten minutes of the 15-minute run, nr_throttled 0, 300.6 →
302.1 MiB. The previous engine read 0.09–0.11 core and 301 MiB on the same
shape.
Not run against 96e6bfb: any cluster but kind; anything longer than
fifteen minutes; --rolls; the frozen-driver and staircase runs
(the driver's code did not change between the two pins); an engine pod deleted
mid-run. Nothing here can show the race that 6a0a866 fixes, or its absence:
the eight executions of every run above start together, which is the path, and
a clean run is what f0750ec gave too.
While comparing: the two earlier 5-minute rows below said 43,200 of 43,200. Their saved reports sum to 36,000 (60 rps on two nodes for 300 s), and the rows now say so.
Evidence on kind (2026-10-08, sortie f0750ec)¶
The same cluster shape, aether install, stand-in targets and UDP listener as
for 9fcbb81 below; every run through run.sh e2e --verify
(verified=cosign-v3.1.2, chart sha256:b1f0a6d5…). Run directory:
<run-dir>/1008-sortie-f0750ec-kind/.
| run | result |
|---|---|
| 5 min, the committed values | 2,700 of 2,700 per 9 rps target on each node, 900 of 900 per 3 rps target: 36,000 of 36,000; every failure class 0; each backend's elapsed time 300,000–300,003 ms; p50 1.9–3.0 ms, p99 2.4–3.8 ms; VERDICT PASS targets=8 passed=8 failed=0 not_run=0 backends=2 lost_backends=0; watchdog verdict=PASS new_restarts=0; SORTIE_SAVED … stream_executions=8 |
the engines capped below the plan (engine.maxConcurrentExecutions: 4, eight targets) |
the Job failed at once (the report's window is 50 ms). run.sh had armed the saver before it looked: saver armed, then 45 s later ABORT the sortie pod … is 'Failed', not Running … what it wrote is saved … [saver: SORTIE_SAVED … job=failed executions=8 pass=false not_run=0 … stream_executions=8] (the 45 s are the saver's one 30 s poll for a Job not yet marked failed, and two reader pods). All eight executions carry "refused": "execution_cap"; five of them list one or both engines in backend_errors, each entry marked, and three list none. Gate: eight REFUSED AT THE EXECUTION CAP … [refused by: …], lost_backends=0. This report and its stream are the fixture kind-capped.* |
| the three-stage staircase of five targets against the same capped engines | stage 1 refused on all five targets (refused on each; backend_errors on one of them, both engines, both marked); the ten executions of stages 2 and 3 are "not_run": true with no refused; the stream has the same fifteen objects; the log ends FAIL 0/5 executions passed, 10 not run. Four of the executions and the log's lines about them are the fixture kind-refused.* |
2 min, and the driver's node frozen (docker pause) from T+46 s to T+300 s, which is 29 s past the 271 s backend deadline and before anything is evicted |
the report was written 2 s after the thaw. All eight executions have results from both backends. The healthy node's engine is in no backend_errors: its complete results, waiting since T+120 s, were taken. The frozen node's own engine is failed on all eight, as it should be: reported an execution of 5m1.011s for one planned to last 2m0s: it stalled, and its results are not those of the plan, counters kept. Gate: lost_backends=1; the healthy node reads whole for the two UDS targets (360 of 360) and http_5xx for the six whose only target pod was on the frozen node |
| the same, frozen for ten minutes (the 9fcbb81 case, like for like) | SORTIE_OVERDUE at five minutes; the pod was evicted with its node and its log lost, as before; on the thaw sortie wrote the report and the stream before it was killed. Again results from both backends on all eight executions and only the frozen node's own engine failed (reported an execution of 10m46.876s …): lost_backends=1, where 9fcbb81 read 2. Sixteen of sixteen executions over the two runs took the healthy backend's waiting answer; with 9fcbb81 four of eight did not |
| two 60 s runs one after the other, the listener on the control-plane node | on the wire, sortie.mesh.tcp_a.sortie_worker.cluster.0.benchmark.http_2xx: 40\|c 45\|c 45\|c 45\|c 45\|c 45\|c 45\|c 46\|c 45\|c 45\|c 45\|c 44\|c 5\|c in the first run, which is the report's 540, then 40\|c 45\|c … again under the same name in the second. Deltas, as sortie's README says; what a receiver makes of them is in "Telemetry" |
Not run against f0750ec: the reference cluster; anything longer than five minutes;
--rolls; an engine pod deleted mid-run; a backend that answers within the
ten seconds after a missed deadline rather than before it (both frozen runs
had the answer waiting). The sortie session could not reproduce the frozen
driver by freezing a node; the two runs above did, by pausing the kind node
that holds the report PV, and therefore the sortie pod.
Evidence on kind (2026-10-07, sortie 9fcbb81)¶
The same three-node kind cluster shape as below (pinned kind v0.33.0, one
control plane and two workers, on a kubeconfig of its own), aether from this
tree as e2e/uds.sh up installs it (SPIRE off, uds-echo, uds-cr-echo,
tcp-echo), and the committed values and plan with the same stand-in targets
file (six 9 rps targets on tcp-echo, the two UDS targets at 3 rps). In place
of a collector, a UDP listener as o11y/otel-scraper that prints what arrives:
it shows the names on the wire and nothing about Prometheus. Every run went
through run.sh e2e --verify (verified=cosign-v3.1.2).
| run | result |
|---|---|
| 5 min, the committed values | pool resolved to 2 backends; 2,700 of 2,700 2xx per 9 rps target on each node, 900 of 900 per 3 rps target: 36,000 of 36,000; every failure class 0, http_inflight_lost among them (the threshold is in the report, actual 0); each backend's elapsed time 300,001–300,002 ms and each execution's 300.77–300.80 s, so nothing was in flight long enough to show; latency from statistics: p50 1.8–2.5 ms, p99 2.2–3.0 ms, max 6.6–79.7 ms; VERDICT PASS targets=8 passed=8 failed=0 not_run=0 backends=2 lost_backends=0; watchdog verdict=PASS new_restarts=0; SORTIE_SAVED … not_run=0 window=…23:26:33.393Z..…23:31:34.192Z stream_executions=8. The Job ran --results-stream /var/run/sortie/<run tag>.jsonl; the stream's eight lines are the report's eight executions, object for object (both compacted with jq -c and compared: identical). The engines ran --backend-name $(SORTIE_BACKEND_NAME) from spec.nodeName, and the datagrams on the wire were named sortie.mesh.tcp_d.sortie_worker.cluster.0.… |
the engines capped below the plan (engine.maxConcurrentExecutions: 4, eight targets) |
sortie 1374999: the Job failed five seconds after it started, every execution with error: backend … refused a start because the engine is at its cap of 4 concurrent executions, and this scenario starts 8 at once on every backend … Raise the engine's --max-concurrent-executions … to at least 8; nothing ran (the report's window is 49 ms). run.sh aborted (the sortie pod … is 'Failed', not Running) before arming the saver, which then saved the report and the stream by hand; the gate fails all eight. This run is why the gate now words a refused stage REFUSED AT THE EXECUTION CAP (it first listed the refusing engines as lost backends) and why the abort says how to read the report |
a three-stage staircase of five targets against the same capped engines (a plan written by hand; run.sh cannot render one) |
the real not_run: stage 1 refused as above on all five targets; the ten executions of stages 2 and 3 are "not_run": true with elapsed_ms: 0, no started_at, no counters, and error: not run: mesh/stage-1 was refused …; the pod's log has a SKIP line for each, twice (as each "finishes" and in the summary), and ends FAIL 0/5 executions passed, 10 not run. The saver: SORTIE_SAVED … executions=15 pass=false not_run=10 … stream_executions=15. One target's three stages and the log's lines about them are the test fixture kind-not-run.* |
11 min with --rolls (agent roll at T+3m, proxy roll at T+8m) |
both ROLLED (23:41:50Z and 23:47:27Z, inside WINDOW dispatched=…23:38:41.939Z ended=…23:49:42.701Z, which is what the line is for); 5,940 of 5,940 per 9 rps target and 1,980 of 1,980 per 3 rps target on both nodes, 79,200 of 79,200; every failure class 0, http_inflight_lost 0; p99 ≤ 3.8 ms, max ≤ 80 ms; VERDICT PASS; watchdog verdict=PASS new_restarts=0 |
2 min, and the node that holds the sortie pod frozen (docker pause) from T+46 s for ten minutes — a mistake in the test (the report PV pins the sortie pod to one worker, and that was the one paused), kept because of what it showed |
the driver froze with its node, so nothing could end the run; the saver said SORTIE_OVERDUE at five minutes; the pod was evicted with the node and its log lost (sortie.log: nothing). On the thaw sortie applied its backend deadline at once (no result 4m31s after dispatch, for an execution planned to last 2m0s: the backend went silent or is far behind) — to the frozen node's engine on all eight targets, and on four of the eight also to the healthy node's engine, whose complete results (1,080 of 1,080) were waiting unread — and wrote the report and the stream before it was killed: SORTIE_SAVED … job=failed executions=8 … stream_executions=8, gate VERDICT FAIL … lost_backends=2. Risk 4 in a new light: a frozen driver node costs the run, but no longer everything it recorded |
| 2 min, and the other worker frozen from T+45 s — the node with an engine and, as it happened, every target pod, but not the sortie pod; never thawed until the Job had ended | sortie 76f699b: the Job ended at T+307 s with the node still frozen, the report written 301.08 s after dispatch (181 s past the planned end: 151 s of deadline, 30 s for a cancellation the frozen engine could not answer). The frozen engine is in backend_errors on all eight executions (no result 4m31s after dispatch, for an execution planned to last 2m0s: the backend went silent or is far behind (… the service did not answer the cancellation within 30s)) and returned nothing; the other node is reported whole and judged: FAIL tcp-a rate=3.44/18rps http_2xx=413/2160 backends=1/2 -- LOST BACKEND sortie-worker (10.10.1.17:8443) [returned nothing] \| survivors sortie-worker2=FAIL \| failures http_5xx=667 [sortie-worker2=667] \| http_2xx below 99% of the planned 1080 on [sortie-worker2=413] — 413 + 667 = 1,080: every request that node was asked for has an outcome (the targets were on the frozen node, and the mesh answered for them with 5xx), http_inflight_lost 0. VERDICT FAIL targets=8 passed=0 failed=8 not_run=0 backends=2 lost_backends=1; no SORTIE_OVERDUE. With 94cf103 this run had no end and no report |
The engine with WAIT as sortie's default and nothing in the plan: 0.09–0.11
core per engine (cgroup cpu.stat, a 40 s and a 60 s window with a progress
snapshot and a proxy roll in it), nr_throttled 0, 301 MiB while running. That
is at or under what 94cf103 read with WAIT templated (0.11–0.14).
Not run against 9fcbb81: a 15-minute run (5 and 11 minutes here); the
deliberate-failure run (a Deployment scaled to zero); an engine pod deleted
mid-run; the collector with the reference cluster's statsd receiver settings, and so the
series' names in Prometheus and their sum against the report; a real
http_inflight_lost (it needs a target that holds a request open across the
end of a run for more than 30 s; the fixture for it is canned from the schema).
Evidence on kind (2026-10-07, sortie 94cf103)¶
A three-node kind cluster (pinned kind v0.33.0, one control plane and two
workers), aether from this tree as e2e/uds.sh up installs it (SPIRE off,
uds-echo, uds-cr-echo, tcp-echo), a collector with the reference cluster's statsd
receiver settings as o11y/otel-scraper, and the committed values and plan
with a stand-in targets file of the same shape (six 9 rps targets on
tcp-echo, the two UDS targets at 3 rps). Every run went through run.sh e2e,
with the provenance check on (verified=cosign-v3.1.2).
| run | result |
|---|---|
| SHORT profile, 15 min, the committed values | pool resolved to 2 backends, one per worker node; 8,100 of 8,100 2xx per 9 rps target on each node, 2,700 of 2,700 per 3 rps target: 108,000 of 108,000; every failure class 0; p50 1.8–2.4 ms, p99 2.1–2.9 ms; VERDICT PASS targets=8 passed=8 failed=0 backends=2 lost_backends=0; watchdog verdict=PASS new_restarts=0; engine 0.125–0.14 core with no limit, nr_throttled 0, 300 → 302 MiB; the per-node statsd series sum to the report for all eight targets; both pods priority=1000. An earlier 15 min with --progress 30s: the same counts, 0.13 core, 300.0 → 301.9 MiB |
one target's Deployment scaled to 0 for 90 s of 240 s, no failure_predicates in the plan |
every execution ran its 240 s. FAIL uds-cr-echo rate=3.7/6rps http_2xx=888/1440 backends=2/2 -- failures http_5xx=552 [sortie-worker=276 sortie-worker2=276] \| rate below 99% of 3 rps on [sortie-worker=1.85 sortie-worker2=1.85]; the seven other targets PASS at 4,319–4,320 of 4,320 and 1,440 of 1,440; VERDICT FAIL targets=8 passed=7 failed=1; gate exit 1. The progress lines place it: http_5xx 0 → 84 → 174 → 264 → 276 at 30 s steps (this run passed --progress 30s), then http_2xx climbing again |
| one engine pod deleted at T+60 s of 240 s | backend_errors names 10.10.2.23:8443 on all eight executions (awaiting execution response: … Unavailable … "Server shutdown") and the report has no execution-level error. Gate: FAIL tcp-a rate=9/18rps http_2xx=2160/4320 backends=1/2 -- LOST BACKEND sortie-worker2 (10.10.2.23:8443) [returned nothing] \| survivors sortie-worker=ok, for all eight targets; the surviving node's counts are whole (2,160 of 2,160, 720 of 720, every class 0); VERDICT FAIL … lost_backends=1. sortie's own rate threshold passed for the six 9 rps targets (one node at 9 rps clears the plan-wide 5.94), which is why the gate reads backend_errors. The restart watchdog says PASS: a replaced pod is not a restart |
11 min with --rolls (agent roll at T+3m, proxy roll at T+8m) |
both ROLLED; 5,940 of 5,940 per 9 rps target and 1,980 of 1,980 per 3 rps target on both nodes; every failure class 0; p99 ≤ 3.6 ms; VERDICT PASS; watchdog verdict=PASS new_restarts=0 |
| the PriorityClass deleted before a run, and again between the engines and the Job | the pre-flight said absent; the run creates it; both times run.sh applied it and read it back, and the Job's pod came up with priorityClassName=aether-soak-loader priority=1000 |
| every engine pod deleted at T+45 s of 120 s | the Job fails at T+77 s; each execution has an error and both backends in backend_errors, no results. Gate: FAIL tcp-a rate=?/18rps backends=0/2 -- LOST BACKEND sortie-worker (…) [returned nothing] \| LOST BACKEND sortie-worker2 (…) [returned nothing] \| no backend survived, lost_backends=2; the saver still saved the report |
one node frozen (docker pause, so no FIN and no RST) from T+45 s of 120 s |
a sortie defect, not a pass. sortie did not treat the silent engine as lost: the Job was still running 4 minutes later in one run and 20 minutes later in another (19 minutes past the plan's end), and finished only when the node was thawed. The engine then answered, and its result was accepted with elapsed_ms 289,510 and 1,263,237 for a 120,000 ms execution, with no backend_errors. The gate fails it (ran off plan (120s) on [sortie-worker=289.51s] \| rate below 99% …), and the saver now logs SORTIE_OVERDUE two minutes past the planned end; neither can produce the report sortie has not written. See "Risks" |
| pre-flight provenance, negative controls | no cosign: provenance NOT VERIFIED, run allowed; the same with --verify: abort; a cosign 2 on PATH: abort ("older than 3"); the b71b37e chart digest (unsigned): no signatures found, abort; an engine image by tag: abort before cosign is asked |
The first version's evidence, against b71b37e on 2026-10-06, is in #1339: a clean 15-minute run under the 400m limit (16,184–16,185 of 16,200 per mesh target), the same deliberate failure with the predicates lifted by hand, the rolls, and both engines OOM-killed at 512 MiB by the first progress snapshot.
The clean roll result is a kind result: one replica per target, SPIRE off, a 20-core host. It shows the driver does not manufacture failures at a hot restart; it says nothing about what a roll costs on the reference cluster.
Risks¶
- Concurrent executions are tested upstream over HTTP/1 only. The soak
drives HTTP/1.1 only, so this is inside what sortie tests. It rules out, for
now, moving the multi-protocol or UDP dialer legs (
mp-dialer,udp-dialer) onto sortie's TCP/UDP modes alongside the HTTP targets, and an h2 or h3 leg next to the HTTP/1.1 one. - ~~An 8 h unattended run is unproven~~: memory over ~9 M requests per node, pacing at soak length, the final report's size and assembly time, 510 progress snapshots. Run once, 2026-10-08, with 9fcbb81 ("Verified on the reference cluster"): 8h30m on five engines, every one of 9,180,000 requests sent and answered, no engine restarted, the report and the stream written and saved 25 s after the last answer. One run; the k6 files stay until phase 4.
- The engine has no CPU limit. It costs 0.11–0.14 core on kind because a
worker waits (
WAIT); nothing else keeps it there. Since sortie 7f338df that is sortie's default and no longer the plan's doing, so what keeps it is the pin: a plan rendered with another idle strategy (--idle-strategy), or a sortie bump that changed the default back, would cost up to eight cores on a four-core node and starve the proxy under test. The test suite pins that the plan templates nothing; the engine's CPU has to be read again at every sortie bump, and has not been read on the reference cluster for 9fcbb81, f0750ec or 96e6bfb (94cf103 read 127–135m per engine, 2026-10-07; neither saved run directory of 2026-10-08 holds a reading). f0750ec's engine has the layers of 9fcbb81's, so the two are one reading. 96e6bfb's engine is a new binary (one lock around a constructor) and has been read on kind only: 0.10–0.11 core and 301–302 MiB per engine, as before ("Evidence on kind (2026-10-08, sortie 96e6bfb)"). - One sortie pod is a single point of failure for the run. If it is
evicted or its node drains, every execution is cancelled and reported
cancelled, not evaluated: 8 h gone. k6 runners failed independently. It hasbackoffLimit: 0on purpose (a retry would start a second, partial run). It now carries theaether-soak-loaderPriorityClass, so it is no longer the first preemption victim; a node drain or a node failure still takes it. - An engine that dies loses its node's share of the run, all eight
targets of it, from the start: a backend that goes away returns nothing, not
what it had counted. The other nodes are whole and graded. A node that
reboots mid-soak therefore fails the loader gate for that node for the whole
run, not from the reboot on; the access logs carry what that node did send.
That is the engine that goes away audibly: its pod is deleted or its node
shuts down, the connection closes, and sortie names it. A node that goes
silent was worse, and sortie 94cf103 did not handle it; 9fcbb81 does (the
paragraph after this one). With 94cf103: a node that
freezes, loses power or is partitioned sends no FIN and no RST; sortie has no
deadline on a backend's answer and no keepalive, so it waits. On kind, with
one node paused, the Job was still running 20 minutes after the pause (19
minutes past the end of a 2-minute plan) and ended only when the node came
back. Until then there is no report for any node: stopping the sortie pod
cancels the run, and a cancelled run is not evaluated. For a soak, a node
that hard-fails and stays down therefore costs the loader's report for all
five nodes — the b71b37e behaviour for a dead engine, by another road. What
the harness can do is say so early (the saver's
SORTIE_OVERDUEline, two minutes past the planned end) and keep what exists: the progress lines in the sortie pod's log are each target's cumulative counters per node, up to one minute old, and the statsd series and the access logs are unaffected. The fix is sortie's: give up on a backend some grace after its execution's duration (and report it inbackend_errors), or keep the connection alive. It went to the sortie session with that PR. Since sortie 76f699b (in the 9fcbb81 pin) it is fixed, the first way. Every backend has a deadline counted from dispatch — the duration, the request timeout (30 s), the engine's drain (1 s) and two minutes of grace: 151 s past the planned end for this plan. A backend that has not answered by then is cancelled, given 30 s to answer the cancellation, and named inbackend_errorsas silent, and the report is written with the other nodes whole; a backend that answers with an execution far longer than planned is failed too, its counters kept. A node that hard-fails mid-soak therefore costs that node's share and three minutes, not the report. Measured on kind ("Evidence on kind (2026-10-07, sortie 9fcbb81)"): with one node frozen from T+45 s of a 120 s plan and never thawed, the report was written 181 s past the planned end. What is left of the risk is the driver's own node: the report PV pins the sortie pod to one worker, and if that node freezes nothing ends the run (the same test, aimed wrong, showed it). It is risk 4, and one node in five. That test also showed a driver waking past the deadline and calling the healthy node silent on four of eight executions, its results unread; sortie f0750ec takes the answer that is waiting (16 of 16 executions over two frozen-driver runs on kind), so a driver node that stalls and comes back costs its own engine's share only. - A node that joins or a pod that is replaced mid-run gets no load. The pool is fixed at start. An engine pod that is deleted and recreated (a node drain) is a new IP the run never drives.
- No per-request response timeout (above): a request that hangs is
counted only when the run ends (
http_inflight_lost, since 9fcbb81), not when it should have been answered. - Series churn — retired with 9fcbb81: the statsd names carry the node's
name, not the pod IP, so a run writes the series the previous one wrote
("Telemetry"). What replaced it as an unknown, how Prometheus shows two
runs on one series, is known now: the engine sends deltas, so a series the
collector still holds carries on from the last run, and one it has expired
(or lost to a restart, also mid-run) starts from zero. A total is not a
run's count;
rate()andincrease()are right either way.
Cut-over¶
- Short e2e on the reference cluster (15 min,
run.sh e2e), k6 not running; the exact commands and what each writes to the cluster are in the README ("The first short e2e on the reference cluster"). Reads: the pool is five backends on five nodes; what the engine really costs on those CPUs, against the 150m request; pacing (http_2xx=<counted>/<planned>per target and node) andpool_overflowzero with no limit and no queue; the latency the gate prints; that the per-nodesortie_mesh_*series arrive and sum to the report; and, in a second run with--rolls, that one agent roll and one proxy roll produce failures the access logs can attribute. Exit:sortie-gate.shPASS on a no-roll run, and every number that was unverified replaced by a reading. Run on 2026-10-07 ("Verified on the reference cluster"): the no-roll run FAILED the gate on 6pool_overflowwith no client queue, which is the finding; the run with rolls and a queue PASSED. The exit condition as written — a PASS on a no-roll run, with the default queue — was missed once more by run 3 (one reset in 270,000, an aether defect, #1350, since fixed) and met by run 4 on 2026-10-08: 8 of 8. - Side by side. One short run with the k6 runner up as well
(
run.shnotes it; the node carries both loads, so this is a comparison of instruments, not a capacity test): per target, sortie's failures against k6's by-target table and against the access logs for both identities. The dashboard change lands here. The k6 runner is taken down withkubectl delete ds/k6-soak-loader, neverkubectl delete -f k6-runner.yaml. - First sortie soak (
run.sh soak), k6 not running. It is graded as a soak and as the 8 h proof of the driver. Exit: prober gates as always, the loader ran 8h30m on five backends, its failures reconcile against access logs the way k6's did. Run on 2026-10-08 ("Verified on the reference cluster"): the loader ran its 8h30m on five backends and had no failure to reconcile; the prober's liveness tier was clean and its mesh_dns tier was not (37 timeouts counted at the time; 40 in the later, complete count; all labelled one node), so the exit was not called at the time. The investigation has since finished: the timeouts came from CPU saturation on one node of that cluster (every runnable thread waiting for a CPU, the proxy's included), not from a defect in the mesh or in the driver. 12 of the 40 sat on a proxy handoff on that node and 28 did not (#1320; runbook, "Sizing nodes for a proxy hot restart"). - Retire k6: delete
k6-mesh-soak.jsandk6-runner.yaml, the k6 sections of the README, theaether-k6dashboard andk6-operator(GitOps), and rewrite the README passages that still say "k6" (the churn script's comments too). Not before phase 3 has passed.
Verified on the reference cluster (2026-10-07 and -08), and what is still open¶
Five runs, k6 not running, five workers at 60 rps per node, WAIT, no CPU
limit: four 15-minute run.sh e2e runs and one run.sh soak. Runs 1 to 3
with sortie 94cf103, run 4 and the soak with 9fcbb81; none yet with
f0750ec or 96e6bfb. Run directories under <run-dir>/: 1007-sortie-e2e-1,
-2, -3, 1008-sortie-e2e-9fcbb81, 1008-sortie-soak. Access-log figures
are the source proxies' rows for user_agent:"aether-soak-sortie" (the LogsQL
is in the README, "The mesh's own numbers"). Runs 1 and 2 first, as they were
written up at the time; runs 3 and 4 and the soak follow.
| run 1 | run 2 | |
|---|---|---|
| rolls | none | aether-agent at T+3m, aether-proxy at T+8m; both ROLLED |
| client queue | none (the plan as it then was) | max_pending_requests: 16 |
| sent, of 270,000 planned | 269,994 | every target 40,497–40,500 of 40,500 (9 rps), 13,499–13,500 of 13,500 (3 rps) |
pool_overflow |
6: svc-1 3 and svc-4 2 on node C, svc-2 1 on node B |
0 |
| every other failure class | 0 | 0 |
| gate | VERDICT FAIL targets=8 passed=5 failed=3 |
VERDICT PASS targets=8 passed=8 failed=0 |
| sortie latency, worst node per target | p50 13–17 ms, p99 115–141 ms | p50 12–16 ms, p99 153–207 ms |
| access logs: rows, whole run | 269,994, all 200, no flag |
269,992: 269,991 200 with no flag, one 200 DC |
access logs: duration_ms |
p50 7–9 ms, p99 66–78 ms, p99.9 259–332 ms, max 0.85–1.2 s (per node); 89 rows over 500 ms | p50 7 ms, p99 106 ms, p99.9 458 ms, max 1.455 s; 168 rows over 500 ms |
| engine | 127–135m CPU, 302–321 MiB per pod; 0 restarts | watchdog PASS, 0 restarts |
Now verified:
- Engine CPU under
WAIT: 127–135m per pod against the 150m request; the request stands. Memory 302–321 MiB. - Five backends on five nodes, each driven for the whole run and reported per node; every rate floor held on every node in both runs.
- The
soak-sortieidentity with SPIRE on: the rows carryspiffe://aether.internal/ns/aether-test/sa/soak-sortie, and run 1's first requests after the 30 s settle were answered like the rest — 269,994 rows, every one200. No first-use failure. - The report PVC: provisioned, written by the sortie pod, read back by the saver in both runs.
- Signature verification on the reference cluster (
VERIFIED=cosign-v3.1.2in bothrun.envs) and the PriorityClass, which the first run created. - One agent roll and one proxy roll under load (run 2): no reset, no connection failure, no 5xx, on any target or node. One data point, not a characterisation of hot restart.
Found:
- The client-queue requirement ("The client queue"). The mesh answered
every request of run 1 with
200; the six that failed the gate were never sent. The overflows line up with ~1 s response-time maxima on the same executions (sortie's progress lines: 1.0–1.1 s). - sortie's latency reads above the mesh's own. Run 1, per node: p50 8–17 ms
where Envoy's source-side
duration_msis 7–9 ms, p99 80–140 ms against 66–78 ms — a few ms at the median, tens of ms at the tail. It includes the client's connection wait and scheduling. It stays a soft signal; the access-log quantiles are the mesh's number. - One
200/DCrow in run 2: node D, toecho, at T+10m08s, 16 s before the proxy roll finished. A complete response (799 bytes, like everyechoresponse; 78 ms),downstream_remote_disconnect, the single request on a second connection the engine opened while its usual one was busy and then closed. The engine counted nothing for it. Why Envoy saw the close first is unexplained; it has the signature of k6's benignDC(#1009), which this proposal expected not to see from an Envoy-based client. One occurrence in 540,000 requests is not a baseline.
Runs 3 and 4: the short e2e with the default queue¶
Both without rolls, both with the plan's default max_pending_requests: 18.
What is in each row is read from that run's saved report.json,
save.log, engines.tsv and restart-watch.log.
| run 3 | run 4 | |
|---|---|---|
| when | 2026-10-07, load from 11:43:08Z | 2026-10-08, 01:25:51Z to 01:40:54Z |
| sortie, aether | 94cf103 | 9fcbb81, aether 2.4.16 |
| sent, of 270,000 planned | 269,999 | 269,981 |
| failure classes | 1: mesh/svc-3 on node C, stream_resets 1 = stream_resets_before_headers 1 = stream_resets_connection_termination 1 (one reset, counted in its class, its phase and its reason). Every other class 0, pool_overflow among them |
every class 0, http_inflight_lost (new in 9fcbb81) among them |
| sortie's own verdict | FAIL 7/8 executions passed (the plan's stream_resets == 0) |
PASS 8/8 executions passed |
| gate | not graded by today's gate, which refuses a 94cf103 report as old-format; by the report: 7 targets clean, svc-3 failed |
VERDICT PASS targets=8 passed=8 failed=0 not_run=0 backends=5 lost_backends=0 report_pass=true |
| short of the plan | 1, the reset | 19, all on node C: 3 on each of five 9 rps targets, 2 on echo, 1 on each UDS target; the four other nodes sent 54,000 of 54,000 each |
| sortie latency, worst node per target | p50 12–16 ms, p99 178–264 ms | p50 12–15 ms, p99 170–228 ms, max 1.1–2.0 s |
| watchdog, engines | verdict=PASS new_restarts=0, 30 samples; no engine restart |
the same |
| saver | SORTIE_SAVED … job=failed executions=8 pass=false |
SORTIE_SAVED … job=complete executions=8 pass=true not_run=0 window=…01:25:51.582Z..…01:40:54.014Z stream_executions=8 |
- Run 3's one reset is aether's, and it is fixed. A reset before any
response header, reason connection termination, on a request to
svc-3: the proxy's 5-minute idle close racing the reuse of an HTTP/1.1 keep-alive connection (#1350). Fixed in #1358, deployed with chart 2.4.14. It did not recur in run 4 or in the soak's 9,180,000 requests, both on 2.4.16. It is the first failure this driver found that the mesh had caused, and the gate placed it: one target, one node, one class with its phase and reason. - Run 4 is phase 1's exit: a no-roll run that passes, with the default
queue. No
pool_overflowin either run (the queue of 18; run 1 had 6 with none). - Run 4's 19 short requests are one node's late start, by the timestamps.
Every worker of node C's engine started between 01:25:52.537Z and
.573Z, 0.31–0.35 s after the first worker of the pool (.225Z); on the other
four nodes the workers started between .225Z and .556Z. And that engine's
own elapsed time is short by the same amount: 899.68–899.75 s on its eight
executions, 899.98–900.06 s on every other node. It started late and
stopped with the rest, and 0.3 s at 9 rps is the 2–3 requests each of its
targets is short. Nothing failed and nothing was in flight at the end
(
http_inflight_lost0); 19 in 270,000 is 0.007 %, inside the gate's 1 % range, which is what the range is for. Why that one engine started late is not known. - First on the reference cluster with 9fcbb81 in run 4: the results stream on
openebs-hostpath(eight lines, the report's eight executions), the timestamps and theWINDOWline, latency fromstatistics, the count judged as a range, and a run that ended 2.4 s after its 900 s with nothing in flight.
The first 8-hour soak (2026-10-08)¶
run.sh soak, sortie 9fcbb81, aether 2.4.16-8f0ff40
(<run-dir>/1008-sortie-soak). Load from 02:01:51Z to 10:31:53Z; the
churn driver's T0 is 02:06:04Z.
| sortie gate | VERDICT PASS targets=8 passed=8 failed=0 not_run=0 backends=5 lost_backends=0 report_pass=true |
| sent, of 9,180,000 planned | 9,180,000: 1,377,000 of 1,377,000 on each 9 rps target, 459,000 of 459,000 on each UDS target, 1,836,000 on each of the five nodes. Not one request short |
| failure classes | every class 0 on every target and node; http_inflight_lost 0; nothing not run |
| churn | 35 ROLLED lines and no FAILED or ABORTED: the 33 rolls (6 proxy, 2 agent, 2 edge, 3 mesh-dns, 2 uds-csi, 18 svc-*) and the 2 new-ServiceAccount steps; the SHRINK done and restored |
| restart watchdog | verdict=PASS new_restarts=0 containers=0 samples=240 error_samples=0 baseline=0 ended=complete, 02:06:10Z to 10:06:11Z |
| engines | the five pods of the start are the five of the end, 0 restarts |
| sortie latency, worst node per target | p50 12–15 ms, p99 388–473 ms, max 2.2–3.5 s. The worst p99 is node D's on all eight targets (388–473 ms there, 136–253 ms on the other four nodes) |
| report and stream | SORTIE_SAVED … job=complete executions=8 pass=true not_run=0 window=…02:01:51.550Z..…10:31:53.620Z stream_executions=8, 25 s after the last answer |
| prober, liveness tier | 0 non-success |
| prober, mesh_dns tier | 37 timeouts counted at the time; 40 in the later, complete count, all labelled one node. Attributed since: CPU saturation on that node, not a defect in the mesh (#1320) |
- The loader gate passed an 8-hour soak with nothing to attribute: no
failure class moved on any target or node across 35 rollouts, so there was
no
FAILline to reconcile against the access logs. The k6 soaks it replaces ended at 0 of 9.18 M as well; this is the same reading from the new instrument, per target and per node. - The soak's verdict is the prober's, as always, and the prober's mesh_dns tier is not clean. Those 37 timeouts are not in the loader's report (it drives the eight targets, not the prober's mesh_dns probes) and their cause was not known when this was written; it is now (the last row of the table above).
- Risk 2 has its first measurement: pacing held for 8h30m (each execution's elapsed time is 30,601.8–30,602.1 s for 30,600 s), every engine lived, and the report was assembled, written and saved.
- The live series did not survive the night whole: one of them read 241,808 at the end where the report has 275,400 ("Telemetry"). The grade never depended on them.
Still open:
- sortie f0750ec or 96e6bfb on the reference cluster at all. Runs 4 and the soak were
made with 9fcbb81. f0750ec changes the driver only, and what it changes (the
refusedfield, the answer taken after a missed deadline) is not something a clean run exercises. 96e6bfb changes the engine's binary (a lock around one constructor, "What sortie 96e6bfb changed"), so its first run there is also the first reading of that engine's CPU on real nodes (risk 3). - The mesh_dns timeouts of the soak (37 counted at the time; 40 in the later, complete count; all labelled one node): no longer open. They came from CPU saturation on one node of that cluster, where every runnable thread waited for a CPU, the proxy's included, and not from a defect in the mesh. 12 of the 40 sat on a proxy handoff on that node and 28 did not. The record is #1320 and the runbook's "Sizing nodes for a proxy hot restart".
- The engine's CPU and memory on the reference cluster with 9fcbb81, f0750ec or 96e6bfb, and over eight hours: not in the saved run directories (risk 3).
- The access-log cross-check for run 3, run 4 and the soak is not recorded
here (rows, codes and flags,
duration_msquantiles, whether a200/DCrow recurred). - h2 and h3: the soak drives HTTP/1.1 only.
- A node that goes silent (risk 5), and which of the two a reference-cluster reboot or upgrade is to sortie: a clean close, or silence.
- ~~Anything past 15 minutes, and the 8 h run.~~ Run once (above).
- ~~The default queue itself, and a no-roll run that passes.~~ Runs 3 and 4 used 18; run 4 passed.
- ~~The statsd path end to end on the real
otel-scraperand Prometheus.~~ The node-named series arrive (sortie_mesh_svc_1_node_c_…was read for run 4 and for the soak); they are not a count ("Telemetry"). Their sum against a report was not taken on the reference cluster and, after that reading, is not worth taking. - ~~Behaviour under more than one proxy hot restart.~~ The soak: six proxy rolls and two agent rolls among 35, no loader failure. What the handoffs of #1093 / #1320 cost shows in the prober, not in this report.
Open questions¶
- ~~Should
WAITbe sortie's default?~~ Answered by sortie 7f338df: it is. The last template line is gone from the plan. - ~~sortie: a deadline on a backend.~~ Answered by sortie 76f699b (risk 5).
- ~~Latency in the JSON report.~~ Answered by sortie 1b3d404; the two always-true carrier thresholds are removed.
- A latency threshold that can fail. The carriers are gone and nothing
judges latency. The 9fcbb81 runs on the reference cluster now give numbers: worst-node p99
per target 170–228 ms over 15 minutes without rolls, 388–473 ms over the
soak's 35 rollouts (136–253 ms on four of the five nodes). Whether a real
latency_2xx.p99 < …is set from them, or the access logs stay the only latency gate, is still to decide; one node sets the soak's number. - ~~A structured marker for a stage refused at the execution cap.~~
Answered by sortie f0750ec:
"refused": "execution_cap", on the execution and on the refusing engines'backend_errorsentries. The gate reads it; the text is read for 9fcbb81 reports only. - Why bash can lose a TERM. Found while testing the watchdog's stop
(#1386): bash 5.2.21 does not run a trap that arrives while it is expanding
a
$(…); it printstrap: line 2: unexpected EOF while looking for matching ')'and carries on (2 of 1,680 timed TERMs). Nothing is orphaned by it, and the watchdog spends its time in a wait where it cannot happen, but every script here that traps a signal has the exposure while it works. - h2 and h3 under concurrent executions are untested — by sortie ("concurrent executions are tested over HTTP/1; the other protocols and modes have not been run concurrently") and by this harness, which drives HTTP/1.1 as k6 did. A QUIC leg on sortie needs that first.
- ~~8 h is unproven (risk 2).~~ Run once, 2026-10-08: the loader held for 8h30m and its gate passed. Whether phase 3 is passed is the prober's call and waits on the mesh_dns timeouts.
- OTLP is absent. The live path is statsd over UDP into the collector's statsd receiver, names without labels. An OTLP sink (Envoy's aborts the engine) or DogStatsD tags for scenario / target / backend would give labels instead of 2,800 name-encoded series.
- What a progress snapshot costs. About 0.6 CPU-second per engine for eight executions ("CPU and memory"). sortie's README says a snapshot allocates no histogram and costs no memory, which holds here; it says nothing about CPU. At one a minute it is +10 millicores per node, and a short burst rather than a level. Whether it can be cheaper is a question for sortie; whether a 0.6 s burst matters at a proxy handoff on a four-core node is one for the reference cluster.
- Should the loader keep
defaultas its identity (serviceAccount.create: false) so old access-log queries keep working? Proposed: no — a named identity is the better instrument, and the side-by-side needs the two loads to be distinguishable. - The e2e profile's rolls.
run.sh e2e --rollsdoes one agent roll and one proxy roll inside 15 minutes; the old workstation e2e did one agent roll and three proxy rolls over 46 minutes. Whether the short profile should grow to that is a decision for after phase 1.