fix: /status must never walk the spool — the second unbounded scan PR #101 missed - #102
Merged
Merged
Conversation
PR #101 claimed '/status is now O(1) regardless of spool size', but the claim was false. The fix moved spool_stats() off the request path but missed a second unbounded scan reached via Registry.pipeline_metrics() -> QueueManager.derive_all_stats(). This path walked every worker key inline, opening and reading each .offset file and .dead.jsonl tail on each request, gated only by a 1-second READ-SIDE TTL that spared the second caller but never the first. On a healthy replica with 5000+ worker keys, /status timed out (60s/120s/150s+) while other endpoints responded normally. This fix mirrors the split already applied to spool_stats: - refresh_all_stats() — async, performs the full scan, populates cache, no TTL gate, the only operation that touches disk for these metrics - derive_all_stats() — now SYNCHRONOUS and cache-only; never touches filesystem, never raises - all_stats_age_seconds() — reports snapshot age (None before first refresh) - _spool_stats_refresher now drives BOTH refreshes each cycle Cold-window correctness: before the first refresh, in_queue and dead stats are UNKNOWN (not zero), so naive reads would fabricate loss. The snapshot carries stats_available: False, and pipeline_metrics suppresses rather than invents: residual is None when unavailable, degraded is only computed when stats are available, and _residual_positive_since is not latched on unknown data. /status now reports metrics.stats_available and metrics.as_of_seconds for full visibility. SECONDARY: test_serve_still_fails_loud_for_a_real_misconfiguration flaked on main (15s TimeoutExpired). Not a guard regression — the test passed twice on branch and fires locally in 0.74s. On the runner, the subprocess stalled during gunicorn startup, never reaching worker boot. Subprocess budget raised to 90s (zero cost on healthy runs) and timeout now raises AssertionError carrying partial stdout/stderr and naming it as wall-clock timeout, not proof of guard failure. Version: 6.7.5 -> 6.7.6 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
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.
/statusstill hangs in production — PR #101 fixed one of its two unbounded walksPR #101 claimed "
/statusis now O(1) regardless of spool size." That claim was false.Measured on team-shared, on a healthy replica (restarts 0, up 3 hours) running the merged 6.7.5:
Neo4j is fast, ingest is fast, only
/statushangs./statushad two unbounded per-keywalks. #101 moved
spool_stats()off the request path and never looked for a second one.The one it missed, reached via
Registry.pipeline_metrics():Gated only by a 1-second read-side TTL — which spares the second caller and never the
first. Exactly the flaw #101 fixed in
spool_stats, in the same handler, left in place.Fix
Same split that already landed for
spool_stats:refresh_all_stats()— async, does the scan (old body verbatim), populates the cache. No TTLgate. The only thing that touches disk for these figures.
derive_all_stats()— now synchronous and cache-only. Never touches the filesystem,never raises.
all_stats_age_seconds()— snapshot age,Nonebefore the first refresh._spool_stats_refreshernow drives both refreshes each cycle. Either one left on therequest path recreates the timeout the loop exists to prevent.
The cold window must not lie
pipeline_metricscomputesresidual = accepted − written − in_queue − dead. Before the firstrefresh,
in_queue/deadare 0 because they are unknown, not because they are zero — so anaive read would inflate the residual by exactly the backlog it cannot see and report a fabricated
loss. A hanging endpoint traded for a lying one is not a fix.
So the cold snapshot carries
stats_available: False, andpipeline_metricsuses it to suppressthe determination rather than invent it:
Critically, the grace-period timer (
_residual_positive_since) is not latched on unknown data./statusnow also reportsmetrics.stats_availableandmetrics.as_of_seconds, so a caller cantell measured zero from not yet measured.
Evidence — measured, not asserted
This is the check I failed to run before calling #101 safe. A/B of the per-request cost against
a synthetic spool of pending keys (offset < size — the expensive shape), on local SSD:
OLD grows linearly with key count — 107× from 0 to 5000 keys. NEW is flat at ~0.0005 ms:
genuinely O(1). And that is on local SSD; on Azure Files SMB every one of those file operations is
a network round trip, which is how it became 60–180 s on team-shared.
End-to-end on the local server (real spool, systemd unit, v6.7.6):
Also in this PR: the CI
serve-guard test flaketest_serve_still_fails_loud_for_a_real_misconfigurationfailed onmain @ 7c021bcwith a 15 sTimeoutExpired. Not a guard regression. The same tree passed CI twice on its branch(
e58bbcd,1f60669), and locally the guard fires in 0.74 s with returncode 3, a fulltraceback, the correct message, and gunicorn cleanly reporting "Worker failed to boot." On the
runner the subprocess had only reached
Starting gunicorn— it never got to worker boot, so theguard never had the chance to fire.
15 s has to cover spawn + a cold import of the whole server package + gunicorn arbiter start + fork
load()before the guard can raise at all. On a cold shared runner that is too tight, andwhen it trips,
TimeoutExpiredsays only "15 seconds passed" while SIGKILL discards the child'sbuffered stderr — so a slow runner is indistinguishable from a broken guard.
servegets a 90 s budget (costs nothing on a healthy run — it exits in under a second, and onlyspends real time when something is genuinely wrong, which is exactly when you want the output).
AssertionErrorcarrying the partial stdout/stderr and stating plainlythat it is a wall-clock timeout, not proof a guard failed to fire.
The assertions are unchanged: still non-zero exit, still the guard's message.
Tests
Existing tests that relied on
derive_all_stats()scanning now callawait refresh_all_stats()first — observation point moved, no assertion weakened.
test_derive_all_stats_caches_within_ttltested a TTL that no longer exists and is replaced by two tests for the new contract:
test_refresh_all_stats_always_scans_no_ttl_gatetest_derive_all_stats_is_synchronous_read_only_and_never_scanstest_cold_cache_suppresses_residual_and_does_not_latch_grace_timerstats_available: False,residual: None,degraded: False, and_residual_positive_sincenot latchedtest_after_refresh_reports_available_stats_and_agestats_available: True, numeric residual,as_of_secondsfloat ≥ 018 tests verified RED against the pre-change source (
AttributeError: no attribute 'refresh_all_stats', andKeyError: 'stats_available'for the cold-window pair).Results:
pytest -m "not neo4j"→ 2011 passed, 7 skipped, 92 deselected.pytest -m neo4j(live containers) → 92 passed.ruff format --checkclean.Version 6.7.5 → 6.7.6.
What I got wrong, so it isn't repeated
I verified
spool_stats()with a unit test that patchesiterdirto raise — which proves thatfunction is clean and says nothing about the endpoint. I never called
/statusagainst a largespool and timed it. When I did deploy-test locally, I did it after the reclaim had shrunk the
spool to 796 bytes, so the second walk covered two keys and returned instantly. I gathered evidence
in the one condition that hides the bug, then reported it as proof of an O(1) claim.
The A/B table above is the check that should have gated #101, and it is now the standing guard.