Repository navigation
fix(api): admission control for perf memory diagnostics (#371) - #425
Conversation
Concurrent and repeated mem=1 requests each walk the whole packet store under its read lock. The tests pin one active scan plus a cooldown that starts at scan completion, 429 + Retry-After (rounded up) for the rest, rejection before the store lock (the test holds its write lock), release before the response write, single-use release funcs, and an unchanged ordinary and storeless /api/perf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…down (#371) handlePerf now admits the opt-in store memory breakdown through a per-Server perfMemLimiter before it touches the store: one scan at a time, none for 5 s after a scan completes. Refused requests get 429 + Retry-After (rounded up) and {"error","retryAfter"} like the Reach cold limiter, without taking the store lock. The slot is released as soon as the walk ends, so a slow-reading client cannot hold it; the release func is single-use and also deferred. Ordinary /api/perf and storeless mem=1 are unchanged. api-spec and OpenAPI document the mem param and the 429. The stale-release test now steps past the cooldown so a non-idempotent release cannot hide behind it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent3 PR#425 #371 — head 9f9e054Status: Implemented and self-verified. The draft is open and every CI job is green. No merge, ready or approve actions were taken. The #271 flake did not appear, so nothing was rerun. Evidence tags: [T] = I ran it. [A] = from reading the code. [K] = from CI. Scope recheck
Per-requirement evidence (red before → green after, plus mutants)"Before" means the new tests run against master's
Mutant run: [T] 15 mutants (M1–M14 plus the "before" handler), each applied to the working tree, run with CI per job (run 38384878638, head 9f9e054) [K]
The #271 flake did not appear, so no rerun was needed. Local verification
Left for the owner
Checklist
|
Review — CS-Macmini PR#425 — head 9f9e054Dom: APPROVE med nits Independent, read-only review. No push, merge, ready, label or Evidence tags: [T] = I ran it here. [A] = read from the code. [K] = from CI. Findings
Nothing here blocks the PR. No correctness, concurrency or security defect found. Points, one by one, against issue #371The issue's three "Requested change" bullets and three "Acceptance" bullets:
I found no issue point that was silently dropped, and no point parked in "Left for the owner" that should have been implemented. Red before → green afterThe author's report is honest that the test-only commit
My own mutants (9, independent of the author's)
Boundaries measured against a real serverLocal Go server built from the merged tree,
Tests I ran on the merged tree
CI on head Invariants
Left for the ownerThe author's four bullets (cooldown length, public vs API-key, process-local state, 429s counted under
Not verified
|
Relates to #371
Problem
GET /api/perf?mem=1is public and runsGetStoreMemoryBreakdown(), which walks every stored transmission and observation under the packet-store read lock. Overlapping or rapidly repeatedmem=1requests each run that full walk. Together they can hold the read lock long enough to stall ingest and eviction, which need the write lock.Plan and change
test(perf): …, red on master): they pin the behaviour below over a fake clock and a parked scan, so nothing depends on wall-clock timing.cmd/server/perf_mem_admission.go):perfMemLimiter, a per-Server(that is, per-process) gate. It admits one scan at a time and refuses new ones forperfMemScanCooldown(5 s) after a scan completes. The zero value is ready to use, as with the Reach cold limiter (perf(reach): bound concurrent cold-cache scans across distinct node requests #341/perf(reach): bound concurrent cold-cache scans across distinct nodes (#341) #358). There areconfigureand hook seams for tests, plusacquiredandrejectedcounters.handlePerf): when there is a store andmem=1, admission is the first thing the handler does, before any store call. A refused request gets429,Retry-After(whole seconds, rounded up) and{"error":…,"retryAfter":N}, the same shape aswriteReachColdBusy429, and returns before any store call. The existingsetRetryAfterhelper writes the header. An admitted request releases its slot as soon as the walk ends, not after the response write, so a client that reads slowly cannot hold the slot. The release function is single-use, and adeferstill covers a panic.docs/api-spec.mdand the OpenAPI entry documentmemand the 429.Unchanged:
/api/perfwithoutmem=1(includingmem=0andmem=true), andmem=1on a storeless server, which still returns 200 with no breakdown and never consults the limiter. The response schema and frontend are unchanged; nothing inpublic/usesmem=1.Why not queue like Reach?
Reach waits up to 3 s for a slot because each request is a page a user is waiting on. The breakdown is an operator diagnostic, and the issue asks to reject the excess, so this limiter refuses at once and never parks a goroutine.
Retry-After while a scan is running
The remaining scan time is unknown, so the limiter advertises the full cooldown. That is the earliest moment a new scan could be admitted.
Tests (
cmd/server/perf_mem_admission_test.go, all under-race)TestPerfMemAdmission_ConcurrentRejectedBeforeStoreLock: one scan is parked after admission. The test holdsstore.muwrite-locked while 16 moremem=1requests run. Any of them that reachedRLockor the traversal would block, and the test would time out. All 16 get 429 +Retry-After: 5, and the parked scan then returns 200 with a breakdown.acquired == 1,rejected == 16.TestPerfMemAdmission_RepeatCooldownBoundary: repeat → 429/5; 1.5 s left → 2; 1 ns left → 1; at the boundary → 200 again.TestPerfMemAdmission_CooldownStartsAtCompletion: a scan that takes 30 s on the fake clock still leaves the full 5 s afterwards.TestPerfMemAdmission_ReleasedBeforeResponseWrite: the response writer blocks, and the next scan is still admitted after the cooldown./api/perfunchangedTestPerfMemAdmission_OrdinaryPerfUnaffected: nomem,mem=0andmem=trueall return 200 with no breakdown and noRetry-After, both during a scan and during the cooldown.TestPerfMemAdmission_StorelessUnaffected: the limiter is held busy andmem=1still returns 200.TestPerfMemLimiter_StaleReleaseIsNoopTestPerfMemLimiter_ZeroValueDefaults15 mutants were all killed; the report comment lists them. The
PerfMemtests also passed 30× under-race.Perf justification
Admission is O(1) under a tiny mutex, on the
mem=1path only. Ordinary/api/perfgains one query-string check. Refused requests do strictly less work than before: no store lock and no walk.Left for the owner
perfMemScanCooldown. A tight polling loop now costs at most one walk per scan time + 5 s. If operators want a different value, it could become a config knob; I have not added one (no need demonstrated yet).mem=1stays public, as the issue scopes it. Putting it behind the API key instead would be a product decision.Invariants
cmd/serverstays read-only: no DB writes and no write locks were added.map[string]interface{}; the 429 body is a fixed literal like Reach's.bash scripts/check-xss-sinks.sh --diff origin/masteris clean.deploy.yml, 1 inrelease-fast-path.yml).🤖 Generated with Claude Code