Repository navigation
perf(reach): bound concurrent cold-cache scans across distinct nodes (#341) - #358
Conversation
/api/nodes/{pk}/reach already collapses a herd on one cache key with
singleflight, but distinct node/window keys scan independently: a burst of
cold keys runs that many windowed observation scans at once and can hold the
whole 4-connection read pool, leaving /api/nodes and /api/packets queueing.
Add a per-server counting semaphore for cold Reach builds (default 2, half
the read pool) with a bounded 3s queue wait, taken inside the singleflight
slot after the cache recheck and released with defer on every exit. A request
still saturated at the end of the wait is shed with 429 + Retry-After and
nothing is cached.
Upstream's non-blocking variant refuses the third build instantly; we keep its
cap but queue first, because a cold report normally completes in about a
second and our front end only auto-retries 503, so an instant refusal would
fail a page whose database is not under real pressure. Queue time costs a
parked goroutine, not a database connection.
Warm hits never reach the limiter, same-key coalescing is unchanged, and
visibility filtering, rank snapshots and response/cache semantics are
untouched.
Relates to #341
Rapport — CS-Macmini PR#358 #341 — head e90f803Status: Implemented and verified — cold Reach scans are capped at 2 across distinct keys with a bounded 3s queue wait and 429 + Retry-After on sustained saturation; 9 new tests, 6 mutants killed, all CI jobs green, and an offline A/B shows unrelated endpoints keep ~8x the throughput and lose a ~9s worst-case stall. Acceptance criteria
Red evidence [T]: the suite first failed to build ( Perf proof (offline fixture only — no production load test)Fixture:
The 3s queue wait was chosen from this measurement: at 1s, the same 6-key burst shed 2 of 6 because a cold scan on this fixture takes ~1s. [A] CI per job (run 37675768680)
No re-runs needed; the known-unstable test (#271) did not fire. [K] Local gates
Residuals / follow-ups (not filed)
|
Independent verification — CS-MacBook PR#358 #341 — head e90f803Independent reproduction of the PR's claims (reach E2E + perf A/B). This is a verification run, not a line-by-line correctness review, and carries no formal verdict. Ran in an isolated scratch tree (branch merged with master), ports 13900–13903; every server was stopped by PID and every port freed afterwards. Evidence tags: [T] executed, [A] analysis/reading, [K] known/caveat. Reach E2E (branch server on the CI-prepared fixture, port 13900)Fixture prepared as in CI:
Perf A/B (offline inflated fixture, never prod)The fixture was copied and its observations inflated to 520,192 rows; one cold Reach scan ≈ 0.29 s. A
Moderate 6-key burst on branch: 6×200, none shed (p50 909 / max 1256 ms); unrelated endpoints max ≤ 38 ms — the queue wait absorbs a transient burst exactly as the PR claims. [T] Conclusion. The mechanism reproduces as documented [T]: without the limiter, cold Reach saturation stalls unrelated endpoints to multiple seconds; with cap 2, the excess cold keys shed Caveats
|
Review — CS-pve-agent2 PR#358 — head e90f803Dom: APPROVE with nits Independent, read-only review of the head above, merged in scratch onto current Findings
There are no correctness defects in the limiter or its wiring. The limiter is a buffered-channel semaphore with a non-blocking fast path, a bounded timed wait and ctx-aware cancellation. Answers to the review points1. Acquire and release. [A][T]
2. Behaviour.
3. Tests. Every case is covered:
Gaps are listed in findings 1 to 3. [T] 4. Perf proof. I repeated it myself on an offline scratch copy of the CI-prepared
No production or staging access was used. [T]
This confirms the claim, and on my 4-core host the effect is stronger than the author measured: on master the unrelated endpoints stall for about 33 s, and with the PR they stay at their idle latencies. Every unrelated probe returned 200 on both builds. My PR runs served fewer of the 48 cold keys than the author's (3 to 4 vs 15). The cap is unchanged; the difference comes from slower concurrent scans on this host, and it fits finding 4. 5. Invariants. [T][K]
Tests and mutants
Own mutants (all applied to
Not verified
|
Relates to #341
Plan
defer.Retry-Afterwhen still saturated at the end of a bounded queue wait; cache nothing for a shed request./api/nodesand/api/packetsstay responsive while Reach is saturated, A/B against master.docs/api-spec.md.What changes
cmd/server/node_reach.goalready collapses a thundering herd on one cache key with singleflight, but distinctpubkey|days|genkeys are independent: N cold keys arriving together run N windowed observation scans at once. The server read pool is 4 connections (OpenDB), so a handful of concurrent cold Reach scans can hold every connection and leave unrelated endpoints queueing behind them.New
cmd/server/node_reach_cold_limit.goaddsreachColdLimiter, a counting semaphore with a bounded queue wait, living onreachState(zero value ready to use, so every*Server— tests included — gets it without a constructor):Retry-After: 2with{"error":...,"retryAfter":2}. Nothing is cached for a shed request and singleflight does not cache errors, so the next request recomputes normally.defer, which covers success, compute error, marshal error, cancellation and panic alike.Evaluation of the upstream design
Upstream (fork PR 2127, deliberately not linked) refuses the third concurrent build instantly. Assessment against our deployment:
app.js api()only auto-retries 503 +Retry-After— never 429. An instant refusal would therefore fail a user's page while the database is under no real pressure.Retry-After: 2rather than the 5 used by the index-loading 503, because a slot frees as soon as one in-flight scan finishes.This is a per-endpoint cap, not global database admission control: it bounds Reach's own cold work and nothing else. Any broader policy belongs with the pool-pressure evaluation in #221, which covers a different endpoint and is explicitly not touched here.
Preserved
Warm hits never reach the limiter (the handler's cache fast path is before the singleflight call). Same-key coalescing is unchanged. Visibility filtering (
visibleReach), rank snapshots (applyReachRank), the blacklist-generation cache key/purge and the response body are untouched; a shed request leaves the cache exactly as it was, and the recovered response is byte-identical to the unsaturated one (asserted in the test).Tests
New
cmd/server/node_reach_cold_limit_test.go(9 tests): many distinct holders bound to the cap, shipped defaults, cancellation while queued, saturation/recovery, plus handler-level coverage for 24 distinct cold keys, 12 identical keys, warm-hit bypass under saturation, compute error, cancelled request, and 429 +Retry-After+ no-cache-poison + identical report after recovery.Six mutants were run and all were killed; see the report comment for the mapping.
Perf proof (offline fixture only, never production)
Derived offline fixture:
test-fixtures/e2e-fixture.dbfreshened + migrated, inflated to 400k observations so a cold Reach scan costs ~1s. Two binaries — master2dc04642and this branch — each restarted cold, measured with the same harness (idle probes, then 48 distinct cold Reach keys in parallel while/api/nodesand/api/packetsare probed for 12s).2dc04642/api/nodes/api/packets/api/packetsthroughput under Reach saturation goes from ~3.7 req/s to ~31 req/s, and the ~9s worst-case stall on both unrelated endpoints disappears. A moderate (6-key) cold burst is still fully served.Gates
go test ./...green incmd/serverand every other module.sh test-all.sh: 233 passed, 0 failed.node test-frontend-helpers.js: 709 passed, 0 failed.test-node-reach-e2e.js,test-reach-rank-e2e.js(14/14),test-issue-1630-reach-mobile-e2e.js(7/7) pass;test-node-reach-coverage-e2e.jsskips (coverage disabled).cmd/serverstays read-only; no newmap[string]interface{}outside tests; no colors;scripts/check-xss-sinks.sh --diff origin/masterclean (no frontend changes); fork guards unchanged at 9 indeploy.ymland 1 inrelease-fast-path.yml.