feat(validation): report celeris's engine error classes, six in the series and all twelve in the tally - #378
Merged
Conversation
FumingPower3925
force-pushed
the
feat/377-error-class-series
branch
from
September 15, 2026 11:00
5bbb48b to
c821e39
Compare
…eries and all twelve in the tally celeris#646 split EngineMetrics.ErrorCount into eleven cause buckets plus StandbyErrorCount, deriving the total as their sum so the parts and the whole cannot drift. The validation tier published the total and nothing else, so celeris#645's adaptive column -- 421 engine errors in a 112-second cell against io_uring's 63 and epoll's 0 -- could be bounded and never named. The refapps now publish all twelve, the checker parses them, the tally carries every one as an end-of-cell total, and SIX reach the per-second series. SIX, not twelve. The series is written once a second for a whole cell and already carried thirty-one columns. A bucket earns one when the question asked of it is "when" and the artifact carries something timestamped to join that against; report.ErrorClasses records the call bucket by bucket, and validation.TestTheSeriesCarriesExactlyTheBucketsDeclaredForIt fails if the declaration and the header ever disagree. In the series: accept_fd_limit, accept_cancelled, accept_other, conn_table_cap, send_peer_gone and the standby split. The three accept buckets record connections that were never answered and leave no other trace anywhere -- no conn-table entry, no close hook, no request -- so a walker's handshake-fail instant has nothing else to join against (celeris#588). accept_cancelled steps deterministically at the promotion, two per io_uring worker per PauseAccept, which is what separates a switch transient from sustained loss. conn_table_cap fires on both the accept and the adoption path, and only the timestamp says which. send_peer_gone is the bucket celeris#645 has to size, and "proportionate to the load" is a rate that needs the per-second denominators only the series carries in the same row; it is also what drowns the total, at 88,010 of 88,776 accepts on io_uring, so without its own column no step in engine_error_count is readable at all. The standby split is the direct analogue of engine_standby_active_conns and engine_standby_close_count, which are series columns for this reason: "the standby's accepts were cancelled at the promotion" needs both at the same second. Tally only: conn_register (a kernel refusal of one syscall, co-timed with an accept or an adoption that the series already stamps), listener_recreate (celeris's own comment at both increment sites reads "the bump is not a rate: it is the one record that the engine lost a listener"; note EngineMetrics.Workers is len(loops) and does NOT drop when the loop dies, so the tally entry is the only record there is), transplant_adopt (tracks TransplantAdoptSlotOccupied one for one, and that counter is already a series column and a gated zero witness -- as a tally entry it is the cross-check between the two accountings), send (a defect when nonzero, and the walker's slow-read ring already carries the instant, both addresses and the verbatim error), and request_body and handler (std only, request-shaped, timestamped by the walker's own per-request record; the open question about them -- why every std cell sat at exactly 45 -- is about a total, not a trajectory). NOTHING here is gated, deliberately. The must-stay-zero witnesses beside these were gated because each counts an event that cannot happen in a correct engine. Every bucket here counts something that legitimately happens, and celeris#646 measured 88,010 SendPeerGone over 88,776 accepts on a healthy io_uring abandon-churn load. A threshold before a run has said what normal looks like per engine would be a number nobody measured. TestNoErrorClassIsAZeroWitness keeps it that way. EngineErrorClasses is a map rather than named fields for the reason EngineZeroWitness is one: celeris#646 leaves no generic "bump the error count" call, so a new branch has to name a bucket and this set will grow, and a field-by-field literal is how celeris#627 silently dropped ten fields. The standby share is deliberately NOT a member -- it cuts the same total along the other axis, so summing the map would double count. Schema 5.14. Additive; older readers ignore every field. Refs celeris#645, celeris#646.
FumingPower3925
force-pushed
the
feat/377-error-class-series
branch
from
September 15, 2026 12:35
c821e39 to
dbd2af5
Compare
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.
Wires the validation tier to celeris's new engine error classes, so the next nightly can name the branch behind celeris#645's adaptive column instead of bounding it.
celeris#646 (celeris main
91def85) turnedEngineMetrics.ErrorCountfrom one atomic a dozen unrelated branches incremented into the derived sum of eleven cause buckets, plusStandbyErrorCount, the adaptive engine's share-by-sub-engine split. probatorium published the total and nothing else, which is why celeris#645 could say only that the two sub-engines alone do not account for 421 errors in a 112-second cell.The repin
Now carried by
main, not by this PR. probatorium#381 (d0cb24c) pinned celeris9aa258c, a direct descendant of the91def85this branch originally pinned, so the branch's own 20-file repin commit was dropped on the rebase as redundant — its resolved tree was byte-identical tomain.The swap is safe by measurement, not by assumption: the
Error*/Standby*field set ofengine.EngineMetricsis identical at91def85and9aa258c(celeris#647 addedTransplant*counters, no new error bucket), andvalidation/refapp/internal/debugvarswas re-run with-raceagainst9aa258c— green. That test reflects over celeris's ownEngineMetricsrather than a hard-coded name list, so it is the direct proof that the twelve-key publisher still matches the newer pin.The wire
debugvarspublishes all twelve keys →checker.ParseDebugVarsreads them intoproperties.Snapshot→ the tally keeps each one's peak →report.Tier1Summarycarriesengine_error_count,engine_error_classesandengine_standby_error_count→ six of the twelve also become per-cell series columns.EngineErrorClassesis amap[string]int64, not eleven named fields, for the reasonEngineZeroWitnessis one: celeris#646 leaves no generic "bump the error count" call, so a new error branch has to name a bucket and this set will grow — and a field-by-field literal is how celeris#627 silently dropped ten fields. The standby share is deliberately not a member of the map: it cuts the same total along the other axis, so summing the map with it in would double count. It travels as its own scalar next toPeakStandbyActiveConns.Because celeris assigns the total from the buckets in
engine.FillErrorClassesand keeps no separate running total (and the adaptive engine sums both sub-engines bucket by bucket),sum(engine_error_classes) == engine_error_countholds inside any single/debug/varsdocument on every engine. The artifact now records both, so the split can be checked rather than trusted.Six columns in the series, six totals in the tally
Not eleven columns. The series is written once a second for a whole cell and already carried thirty-one columns. A bucket earns one when the question asked of it is "when", and the artifact carries something timestamped to join that against — the promotion instant (
adaptive_switches), the hand-off (engine_transplant_adopted), a walker's slow-read or handshake-fail fire, or the per-second denominators a rate needs (engine_requests_total,active). A bucket whose question is "how many, over the cell" does not: 3,600 rows of zeros and one step is not a better answer than the total.The call is recorded per bucket in
report.ErrorClasses(aSeriesflag plus a proseWhy), andvalidation.TestTheSeriesCarriesExactlyTheBucketsDeclaredForItfails if the declaration and the CSV header ever disagree in either direction.In the series (
engine_error_accept_fd_limit,engine_error_accept_cancelled,engine_error_accept_other,engine_error_conn_table_cap,engine_error_send_peer_gone,engine_standby_error_count):accept_fd_limit,accept_otherhandshake-fail-timeoutinstant has nothing else to join against (celeris#588), which is the whole reasonengine_error_countwas put in the series in the first place.accept_cancelledPauseAccept— so joined againstadaptive_switchesin the same row it is what separates a switch transient from sustained accept-side loss.conn_table_capengine_transplant_adoptedsays which one a cell hit.send_peer_goneengine_error_countis readable at all.standby_error_countengine_standby_active_connsandengine_standby_close_count, both series columns for exactly this reason. "The standby's accepts were cancelled at the promotion" needsaccept_cancelledand this at the same second.Tally only (
conn_register,listener_recreate,transplant_adopt,send,request_body,handler):conn_register— anepoll_ctlADD refusal. A kernel refusal of one syscall, not a load-shaped rate, and co-timed with either an accept or an adoption, both already timestamped in the series.listener_recreate— celeris's own comment at both increment sites: "the bump is not a rate: it is the one record that the engine lost a listener". At most one per loop, and the loop returns immediately after. Worth noting against the obvious objection:EngineMetrics.Workersislen(e.loops), a static slice length that does not drop when a loop dies, so the tally entry is the only record of the capacity loss that exists — which is an argument for recording it, not for sampling it 3,600 times.transplant_adopt— tracksTransplantAdoptSlotOccupiedone for one by celeris's own construction, andengine_transplant_adopt_slot_occupiedis already a series column and a gated zero witness. A second column with provably identical values buys no timestamp. In the tally it earns its keep as the cross-check: the two disagreeing means one of the two accountings is broken.send— nonzero at all is a defect, and when one fires the walker's slow-read ring (schema 5.9) already carries the instant, both socket addresses and the verbatim error, at far higher fidelity than a 1 Hz column. Promote it if a run ever shows it moving in bulk.request_body,handler— std only, request-shaped, decided on the request path: every instance is a request the walker sent and got a status back for, so the walker's own record timestamps them at per-request resolution. And the standing question abouthandler— why every std cell in nightly 34918161309 sat at exactly 45 — is a question about a total, not a trajectory.Nothing here is gated, on purpose
The must-stay-zero witnesses these sit next to were gated because each counts an event that cannot happen in a correct engine, so one is a failure. Every bucket here counts something that legitimately happens: a client that abandoned a response, an accept cancelled by the
PauseAccepta promotion performs, a handler that returned an error. celeris#646 measured 88,010SendPeerGoneover 88,776 accepts on a healthy io_uring abandon-churn load. Until a run says what normal looks like per engine, any threshold would be a number nobody measured.checker.TestNoErrorClassIsAZeroWitnesskeeps it that way: a bucket that quietly acquires aZeroWitnessMeaningentry fails the build rather than the next nightly's normal traffic.The sum identity is recorded in the artifact and not gated either, for a concrete reason: celeris documents its bucket snapshot as individually atomic but not mutually consistent, and the adaptive engine reads two sub-engines in sequence, so a live cell can legitimately publish a document whose parts are a few errors behind its total. Failing a cell on that would be failing on a race in the measurement.
Schema
SchemaVersion→ 5.14, with a changelog entry. Rebased ontod0cb24c, which is still at 5.13: probatorium#369 took 5.12 (per-cell run outcome) and probatorium#379 took 5.13 (resume provenance), so this is renumbered from 5.13 to 5.14 and all three changelog entries are kept.The literal is pinned as a bare string in three places, and they are the trap: on the rebase,
report/schema_test.go,mage_bench_sutenv_test.goandvalidation/runner_test.goauto-merged with no conflict because both sides had written"5.13"— and so didconst SchemaVersionitself. Only the changelog hunk conflicted. All four were re-pinned to 5.14 by hand, and control M below proves each one now bites. The two stalewant 5.9messages next to two of them are corrected while there. The5.0/5.2/5.3literals elsewhere are deliberate legacy-decode fixtures and are left alone.Rebase notes
Rebased onto
d0cb24c. Twenty conflicts, every one the same two-line celeris pin in ago.mod/go.sum, all resolved to main's newer9aa258c; the branch's repin commit then had nothing left to say and was dropped. The feature commit applied with no conflict, becaused0cb24ctouches nothing but pins.go mod tidywas re-run in all ten modules afterwards and produced zero drift, so thego.sumhashes are the real ones rather than hand-edited. Against the pre-rebase tree the only change anywhere is those 20 pin lines: every Go source file is byte-identical, which is why the control table below still stands (M and I were re-run on this tree regardless — see Verification).fanInLines(mergedW, &lineMu, …). Nothing in this change goes near it: the series is a dedicated per-cell file (os.Createofproperties_series.csv, its own handle, its owncsv.Writer), the buckets reach the checker as an HTTP JSON document over a socket, and the tally reachesvalidate-results.json. No added or removed line in this diff mentions stdout or stderr. Nothing here assumed the old byte-oriented merge.Status/FailureReason, matrix loop moved intomatrix_run.go) — no overlap. This change does not touch the matrix loop, and thereport.Tier1Summaryfield block it extends is disjoint fromValidationCellResult, where both of those landed. After resolving, the declarations for all three new fields were checked to have survived alongside theirs — that keep-the-comment-drop-the-declaration slip is the failure mode fix(resume): rescue the results before the purge, and merge them into the resumed run's verdict (#376) #379 hit.Tests, and the controls that prove they bite
The series column-count guard failed loudly as intended (
test covers 31 columns but the writer has 37). Beyond extending it, every new column got a distinct nonzero fixture and the test now enforces distinctness — which caught a pre-existing hole:engine_transplant_detachedandengine_transplant_adoptedwere adjacent and both96, so swapping them passed. They are now 96 and 97.Thirteen deliberate mis-wires, each reverted. All re-run on the rebased tree; the output below is the post-rebase run, not carried forward:
column "engine_error_accept_cancelled" = 31, want 29/column "engine_error_accept_other" = 29, want 31columns "engine_transplant_detached" and "engine_transplant_adopted" both expect 96; swapping them would passcolumn "engine_error_send_peer_gone" expects 0; a zero fixture cannot tell a wired column from an unwired onewith only EngineErrorAcceptCancelled set: bucket "engine_error_accept_other" = 7, want 0bucket "engine_error_handler" = 0 after a snapshot that set every field to 1+buckets sum to 110 but EngineErrorCount = 132snap.EngineErrorConnTableCap = 0, want 7celeris.engine_error_listener_recreate: missing or not a number: <nil>celeris.engine_standby_error_count: missing or not a number: <nil>Seriesin the declaration onlyreport.ErrorClasses marks "engine_error_send" as a series bucket and seriesColumns does not carry itseriesColumns carries "engine_error_handler" but report.ErrorClasses declares it tally-only"engine_error_send_peer_gone" is declared both an error class and a must-stay-zero witness; the gate would fail a cell on load-proportional trafficengine_error_classes = map[], want map[...]+projected buckets sum to 0 against engine_error_count 160const SchemaVersionto 5.15, leave the three bare literals at 5.14SchemaVersion drift: want 5.14 got "5.15"/missing "\"schema_version\": \"5.14\"" in document/schema_version="5.15" want 5.14G is the strongest of them: the refapp test reflects over celeris's own
EngineMetricsfields rather than a list of today's names, so a twelfth bucket added upstream fails in the refapp that has to publish it rather than in a nightly that quietly reports one bucket short.L covers the celeris#627 blind spot directly:
TestTier1SummaryExportsEveryTallyFieldcannot see the property tally because it is a nested struct, andrunner.go'sreport.Tier1Summary{...}literal is projected field by field.M is new this round, and it exists because the hazard is real rather than hypothetical — see Schema above.
Verification
All post-rebase, on
d0cb24c, run in the foreground on a clean worktree:go build ./...— clean.go vet ./...— clean.gofmt -l .— clean.go test ./report/... ./validation/... ./validation/checker/... ./validation/properties/...— green, 8/8 packages (validation109.0 s,properties10.6 s,remote8.3 s).go test -tags mage ./...— green, 23/23 packages. Flagged because probatorium#384 exists precisely because these run in no workflow: they are green here, on this branch, today. The magefiles carry//go:build mage, so plaingo testskipsmage_bench_sutenv_test.goentirely and the lint workflow only compiles it; its schema pin was therefore exercised explicitly, both at 5.14 and under control M.validation/refapp/internal/debugvars—go test -count=1 -race -timeout=5m ./...green against main's9aa258c, not the91def85this branch was written on. All eight refappsgo build ./... && go vet ./...— clean. (Both are whattest.yml'srefappsjob runs.)go mod tidyin all ten celeris-dependent modules — zero drift.golangci-lint run ./...(v2.13.2) — 0 issues, root module anddebugvarsmodule both.const SchemaVersionmoved to 5.15 and restored from acpbackup afterwards (checksum verified identical):report/schema_test.go:399: SchemaVersion drift: want 5.14 got "5.15"validation/runner_test.go:78: missing "\"schema_version\": \"5.14\"" in documentmage_bench_sutenv_test.go:295: schema_version="5.15" want 5.14series_test.go:198: report.ErrorClasses marks "engine_error_send" as a series bucket and seriesColumns does not carry it+series_test.go:212: series carries 5 declared buckets, want 6.cluster-oklabel and a race tier is live.Refs celeris#645, celeris#646.