test(matrix): fault suite for both frontends, and the defects it found - #323
Open
jaronoff97 wants to merge 9 commits into
Open
jaronoff97 wants to merge 9 commits into
jaronoff97 wants to merge 9 commits into
Conversation
Injects faults on both sides of the edge — sender to edge, and edge to
intake — and runs every case against both frontends.
Neither frontend is the oracle. The first draft treated httpz as correct and
marked every stdio difference as acceptable, which hid four httpz defects.
Each case now asserts what a customer needs, and both frontends are judged
against it. Behaviour we have already found is declared with
DEFECTS = {frontend: note}: the assertion still runs at full strength, the
runner reports it as xfail with the note, and reports XPASS the day it starts
passing.
The intake is the real echo server, which gains ten fault modes armed over
HTTP (POST /fault?mode=&arg=&count=): status, slow, hang, close_early,
reject_early, reset, garbage, truncate, oversize, stale_keepalive. `count`
faults only the next N requests, so a case can break the first attempt and
require the retry to succeed. /stats reports whether the fault fired.
34 cases, one per file, over three surfaces: sender framing and encodings,
the upstream leg, and capacity. Every case also asserts the invariants that
hold whatever the fault was: the process lives, health still answers,
connection slots return to baseline, and every 5xx left a log line.
Three defects found and fixed here:
1. stdio never retried any batch. Every body with a Content-Length was
streamed, and a stream cannot be replayed, so a single stale pooled
connection lost it. Log intake clients do not retry, so this was silent
data loss. Bodies below the streaming threshold are now resident, which is
where httpz already drew the line.
2. A header flood answered 502, sending the agent into a retry loop it could
never win. Our cap and the sender's fault, so it answers 431.
3. A retried dial was never counted, so a dial storm was invisible in
edge_upstream_retries_total.
Eight remain open and are declared, three of them on both frontends: an
undecodable body is dropped when policies are loaded rather than failing open;
a truncated intake response is reported as 202; an unsupported encoding drops
the batch. IMPLEMENTATION-PROGRESS.md carries the full list.
zig-out-stdio/ is now ignored; the previous commit captured its binary.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — Although the PR adds a valuable development-only fault matrix, it also changes production routing, retry and buffering semantics, overload handling, policy failure behavior, response relaying, and shutdown across both frontends. Its 101-file, 4,499-line scope and cross-component runtime impact require human review. Not approved because:
Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more. |
Before this, 19 of 31 case files asserted nothing about metrics or logs, and
the one universal rule was weak: if a 5xx appeared, some line mentioning
`request.failed` or `upstream` had to appear somewhere. A fault an operator
cannot see is still a fault, so telemetry is now part of the expectation.
Cases declare it:
EXPECT_METRICS {series: minimum delta} for both frontends
EXPECT_METRICS_FOR {frontend: {...}} for a frontend specific series
EXPECT_LOGS substrings that must appear
EXPECT_LOGS_FOR {frontend: [...]}
FORBID_LOGS substrings that must NOT appear
FORBID_LOGS is the half that earns its keep: it is how a sender-side fault is
kept from ever reading as an intake fault, which is the confusion that sent us
to the wrong subsystem in the first place.
On top of that, a counter that moved must be explainable from the log
(METRIC_NEEDS_LOG), so a case that declares nothing still cannot pass with
silent telemetry. 24 of 30 case files now declare expectations: 19 metric
assertions, 13 log assertions, 14 forbidden-line assertions. Verified by
breaking one expectation on purpose and watching the case fail.
Five harness defects surfaced while adding this, each of which had produced a
false finding:
- Both edge streams were captured into one file. The edge writes INFO and WARN
to stdout and ERROR to stderr through independent buffers, so the file
interleaved and cut lines in half: `request.failed` landed at byte 0, ahead
of the startup lines. The streams are captured separately now.
- Case-scoped logs were sliced by byte offset, which assumes one
append-ordered file. They are diffed by line now.
- Log assertions read once, so a line that arrived just after the response
read as missing. They poll now, and the forbidden-line check settles first.
- "Every 5xx must leave a log line" was wrong: a 5xx relayed from the intake
is not our failure and owes no log. The pairing rule replaces it.
- FORBID_LOGS = ["upstream"] matched the startup line `upstream.configured`.
Also corrects b01: a refused dial logs `upstream.retried`, not
`upstream.connection.error`.
…elay Three fixes the fault matrix found, all in our own code. The streamed policy path dropped a body it could not decode. With a policy loaded, an undecodable batch answered 400 and never reached the intake, while execPipeBuffered already failed open for the same condition. The edge must never be the reason data disappears: only the intake can accept or reject a payload. Both stages now forward the raw batch, emit policy.failed.open with the stage, and count a module error. Caught by a09. A truncated intake response was reported as 202. An intake that declares more body than it sends has not accepted the batch, and an agent that reads 202 deletes its copy. relayResponse now compares the bytes copied against the declared content-length and fails with UpstreamResponseTruncated (502), so the frontend closes without finishing the body and the sender retries. Caught by b11. httpz dropped request headers above its own cap in silence. Its default max_header_count is 32, below our 64-header forward cap, so a request with more headers was forwarded incomplete and still answered 202. The httpz config now sits above our cap, so our cap refuses the request with 431 instead. MAX_FORWARD_HEADERS moves to limits.zig as the single source of truth. Past httpz's raised cap it truncates again, which needs a fix in httpz itself. Two earlier findings were wrong and are corrected in the suite: - httpz handles an unsupported request encoding correctly. The 502 came from our own std.http.Server based echo intake refusing brotli. Verified against a lenient intake: httpz forwards the batch and relays 200. stdio still cannot accept it, because std maps content-encoding through ContentEncoding.fromString and fails the whole head for anything else. - httpz does cut off a dribbling sender: its request timeout runs from accept, not per read. The runner reported XPASS and the declared defect was removed. The suite also gained a guard against a mistake of mine: a stdio build without --prefix overwrites zig-out/bin/edge, so the harness now reads the frontend each binary reports at startup and fails if it does not match.
jaronoff97
added this pull request to stack #324
September 17, 2026 01:29
One row per case: what the fault injects, what must happen, and whether a frontend carries a declared defect for it. Three tables, one per surface: the sender to the edge, the edge to the intake, and capacity.
…challenges Fourteen cases from BACKLOG.md, chosen by whether a customer would hit them and whether the harness can express them honestly. The framer and OTLP torture cases are left out: they belong next to the framers in the Zig unit tests, which already cover escapes, brackets in strings and truncation. The idle-close race is left out because the backlog is right that it cannot be hit deterministically. Soak mode and the tiny profile are runner projects. Three claims were verified against the running edge before any case was written, and two of them did not hold as stated: - a22 holds, and is worse than described: both frontends forward HEAD and POST /_health to the intake, because the route is GET-only and everything else falls through to the passthrough. An ALB or ECS check configured for HEAD tests Datadog, not the edge, and fails whenever the intake is unreachable. - a30 holds, but not against a stale pooled connection, which is caught before the body moves. The failure that matters is mid-exchange: a 300 KiB batch cannot be replayed and becomes a 502, while a 60 KiB batch is retried and succeeds. Agent batches routinely exceed the threshold. - a32 does not reproduce. A query with a bare percent sign and a space forwarded fine on both frontends. The two challenges are now settled by tests rather than by argument. "Log intake clients do not retry" is wrong, and several of my earlier severity claims rested on it. From comp/logs-library/client/http/destination.go in DataDog/datadog-agent: 400, 401, 403 and 413 increment payloads_dropped and are never resent, while every other error status and every transport failure becomes a RetryableError with exponential backoff. harness/agent.py records that with the citation, and a new invariant asserts it: answering a drop-class status for a batch the intake never received is permanent data loss. That raises the stakes on our 4xx choices and lowers them on our 5xx choices. The b11 note was half right. b11b separates the halves with a new echo mode. When the intake truncates before reading the body it received nothing, so our 502 and the agent's retry repair it. When it reads the whole batch and then truncates, the batch is already in, and our 502 makes the agent deliver a second copy: measured at exactly two. New findings from the cases: - stdio takes 30 s to shut down with idle keep-alive connections, against httpz's 2 s, because shutdown waits out the idle deadline. Every deployment stalls a task against the orchestrator's kill timeout. (d03) - A body that expands past the decoded cap answers 413, which the agent discards for good, rather than forwarding what we cannot decode. (a35) - stdio forwards an absolute-form target upstream as a path. (a22) - Health is shed at capacity rather than reserved. (c05) Also adds three universal invariants, each of which caught something the per-case assertions missed: no phantom success, in-flight back to zero, and descriptors back to baseline.
…de budget Four decisions from the backlog review, three of them fixed here. The health route was GET-only, so `HEAD /_health` and `POST /_health` fell through to the wildcard passthrough and travelled to Datadog. A load balancer configured for HEAD therefore tested the intake rather than this process, and failed whenever the intake was unreachable — the same shape as the incident this work started from. The route claims every method now: 200 for GET and HEAD, which RFC 9110 requires wherever GET is served, and 405 for the rest. `/_edge/*` had the same hole in both frontends and gets the same treatment. The phantom-success invariant then caught a second layer: `classifyKnownPath` matched the control paths by method, so a HEAD probe was labelled `other` and counted in the data-path series. Both control paths are labelled by path now. stdio took 30 s to shut down with idle keep-alive connections, against httpz's 2 s. `Lifecycle.shutdown` cancels the group, but cancellation does not reach a task parked in a poll, so every connection waited out its own idle deadline while the orchestrator counted down its kill timer. The conn slab records the socket for each claimed slot, and `stopAccepting` interrupts them all — the same technique the upstream watchdog already used. Exit is now under 2 s. A body that expanded past the decoded cap answered 413, and the Datadog agent discards a 413 permanently (see bench/matrix/harness/agent.py). The two size limits mean different things to a sender, so they are split: the raw cap keeps 413, because the sender can split the batch, and a decoded-size overrun fails open, because the sender cannot see our decode budget and the intake is the party entitled to judge the payload. The fourth, a batch above the streaming threshold not being replayable, is documented rather than changed. The agent retries a 5xx with backoff, so the cost is a delayed batch and a duplicate risk, not lost data, and making every batch replayable would hold up to `max_body_size` per concurrent request: the memory a policy deployment already pays and a passthrough deployment does not. The trade is recorded at the decision in stdio/conn.zig and in the a30 case; exposing the threshold as configuration is the follow-up. a22, a35 and d03 now pass on both frontends, and the health route gains three unit tests.
Twenty-eight cases from the backlog, everything with a category and number that does not need a particular platform, plus the stdio fixes they justify. Fixes: - stdio keeps a control reserve. Two slots are held back from ordinary connections; a connection taken from the reserve serves one control request and closes, and anything else is shed after the head rather than before it. A health probe now answers while the slab is full, instead of being shed and restarting the sidecar during the very spike that filled it. (c05) - stdio accepts an absolute-form target and routes on its path, per RFC 9112 §3.2.2. A sender configured with a proxy was having the whole URL treated as a path and forwarded to the intake. (a22) - httpz relays response headers up to our own cap. Its default of 16 truncated an intake answer in silence, so a Retry-After on a 429 vanished while the request still reported 202: the mirror of the request-side fix. (b17) New intake fault modes: keep_alive_off, bodiless, redirect, header_flood, http10_no_length, slow_body, slow_read, accept_silence, read_then_close and truncate_after_read, plus the last request target in /stats for target fidelity. Two corrections to claims I made earlier: - `.*` is not a policy defect. Hyperscan refuses a pattern that can match an empty buffer, so `.*` never compiles, and `.+` or `^.*$` is how to say "everything". The real finding is narrower and worse: the matcher builds nothing (`matcher.index.build.completed ... policy_count=0`) while the loader reports `loaded_count=1 failed_count=0` and `/_edge/policies` lists the policy as enabled, so a rule that cannot compile looks live. Declared on both frontends. (a41) - The 204 re-framing is stdio only; httpz keeps it bodiless. (b16) Also found and declared: stdio refuses an uppercase `GZIP` content-encoding with 400, which the agent discards permanently, because `std.http.Server` matches content codings case-sensitively though RFC 9110 §8.4.1 says they are case-insensitive. (a34)
The two outstanding defects that were ours rather than a dependency's. A policy whose pattern the matcher refuses stays in the snapshot and evaluates nothing. policy-zig already records the reason per policy, and the edge never read it, so an operator saw `loaded_count=1`, `failed_count=0` and `enabled=true` for a rule that does nothing at all. `/_edge/policies` now leads with `# REJECTED id=<policy>: log: match[0]: invalid regex ".*"`, `edge_policies_rejected` counts them, and `policies.rejected` warns when the count changes. Hyperscan refusing `.*` is correct; hiding it was not. The stdio relay opened a streamed body for every status, so a bodiless 204 came back framed as chunked, which some clients reject outright. 204, 304 and 1xx now answer directly and the relay writes into a discard. Both cases flipped from declared defects to passing on both frontends: 80 cases, 0 failures, 6 known defects, all of which now need a change in std, in httpz, or a decision about the streaming threshold.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
Merge bottom to top. Based on
stdio-deadlines-observability, so review #322 first.What
A fault matrix under
bench/matrix/: faults injected on both sides of the edge — the agent that sends to us, and the intake we send to — with every case run against both frontends.Neither frontend is the oracle. My first draft treated httpz as correct and marked every stdio difference as acceptable, which hid four httpz defects. Each case now asserts what a customer needs, and both frontends are judged against it. Behaviour we have already found is declared, not excused:
The assertion still runs at full strength. The runner reports
xfailwith the note, andXPASSthe day it starts passing, which means the note must go.The intake is the real
echo-server, which gains ten fault modes armed over HTTP (POST /fault?mode=&arg=&count=):status,slow,hang,close_early,reject_early,reset,garbage,truncate,oversize,stale_keepalive.countfaults only the next N requests, so a case can break the first attempt and require the retry to succeed.34 cases, one per file, one class per case, over three surfaces: sender framing and encodings (
a*), the upstream leg (b*), and capacity (c*). Every case also asserts the invariants that hold whatever the fault was: the process lives, health still answers, connection slots return to baseline, and every 5xx left a log line.Defects found and fixed here
Content-Lengthwas streamed, and a stream cannot be replayed, so one stale pooled connection lost it. Log intake clients do not retry, so this was silent data loss. Bodies below the streaming threshold are now resident, which is where httpz already drew the line. Caught by b04, b09, b10.edge_upstream_retries_total. Caught by b01.Defects found and still open
Both frontends:
service/datadogdocuments fail-open. The edge becomes the reason the data disappears. (a09)httpz only:
stdio only:
IMPLEMENTATION-PROGRESS.mdcarries the full list and the task breakdown.Notes
zig-out-stdio/is now in.gitignore; the previous commit captured its 3.2 MB binary, and this one untracks it.requestsandzstandardcome fromuv run --with.ohastays a brew dependency, asbench/perf/run.shalready assumes, and the load cases skip without it.🤖 Generated with Claude Code