fix: crash recovery must not block the HTTP surface at startup - #99
Merged
Merged
Conversation
Move all four crash recovery phases into an asyncio.create_task() so the HTTP surface (serving /version and /status) opens immediately, regardless of spool size. Production incident 2026-09-09: recovery phases walked ~1700 session keys across 5000+ files on Azure Files SMB, blocking uvicorn lifespan completion and the HTTP socket binding. TCP probes saw the port open but no requests were answered for 231s, causing APIM gateway timeouts and 1493 event drops. Changes: - All four recovery phases move into new _startup_recovery(app) via create_task. The sweep loop is awaited inside the same task; the task is cancelled before shutdown_workers() so recovery cannot spawn drainers into a quiescing registry. - _startup_recovery wraps _startup_recovery_body: re-raises CancelledError, records recovery_error on app.state, logs via logger.exception, and sets recovery_complete in finally (fail-loud, not silent garbage collection). - /status gains recovery_complete and recovery_error for observability. - Untagged-:Node boot guard removed (inert dead data; all node-creating statements use MERGE with :Node tag). Detection moved to operator diagnostic path. Verification performed: - pytest -m "not neo4j": 1984 passed, 7 skipped, 92 deselected - tests/test_main.py: 89 passed - 4 new guards + 5 reworked recovery tests verified RED against main (10 failures on old source, 89 passed on new) - ruff format --check: clean on all touched files Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…a /version and /status PR #99 fixes crash-recovery blocking ASGI startup, leaving /version, /status, and POST /events unreachable for over an hour. This version bump ensures deployed instances are identifiable — `GET /version` and `/status` both read importlib.metadata(pyproject.toml [project].version) via SERVER_VERSION, and tests/routers/test_version.py pins the entire chain. Verification: ✓ Editable package reinstalled; SERVER_VERSION=6.7.3 ✓ tests/routers/test_version.py (6 passed) ✓ pytest -m 'not neo4j' (1984 passed, 7 skipped, 92 deselected) ✓ pytest -m neo4j (92 passed) Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Diego Colombo (colombod)
added a commit
that referenced
this pull request
Sep 10, 2026
… are processed (1) /status was calling spool_stats() inline, which scanned the entire queue directory — stat() per file plus open+read of every .log's .offset. On a ~5000-file SMB share this caused thousands of round trips per request, with only a 5s cache TTL to spare subsequent callers. Measured today: /version returns in 0s, /status times out at 180s on the same replica. Fix: spool_stats() is now synchronous cache-only (never touches filesystem, never raises). New refresh_spool_stats() performs the scan; new _spool_stats_refresher background task runs it on its own cadence (default 60.0s), following the create_task/cancel-in-finally pattern established in PR #99. A failed refresh is logged; the loop continues. /status's spool block now includes as_of_seconds to state staleness explicitly. Result: /status is O(1) regardless of spool size. (2) commit() writes only .offset; .log files were never trimmed. Only delete_drained() (called solely from _finalize_session) reclaimed space. A session never finalized cleanly kept files forever; every boot re-walked them. The drain loop's idle branch now also calls delete_drained() — the same safe, idempotent, file_lock-guarded call finalization already makes, just earlier and repeatedly. Logic is conservative: refuses while uncommitted bytes remain, keeps .dead.jsonl, anticipates log recreation. Dead-letter files are deliberately NOT auto-purged (they are the failure record). Version: 6.7.4 -> 6.7.5 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.
Problem —
/statusand/versioncan be unreachable for minutes, and no health signal shows it/versionreturns a constant string./statusreads in-memory counters. Neither touches Neo4j. Both were unreachable on the team-shared deployment for over an hour today.Why: uvicorn runs the ASGI lifespan to completion before it handles a single request —
uvicorn/server.py:await self.startup()and only thenawait self.main_loop(). gunicorn bindsthe listening socket before that. So during startup the process accepts TCP connections and
answers none, however cheap the route.
lifespan()did crash recovery inline, beforeyield:Captured from the live deployment, 2026-09-09:
A ~5000-file spool (~1700 session keys) on Azure Files SMB kept phases 1–3 busy for minutes.
Meanwhile:
tcpSocket:8000, which only proves the port is open — somethinggunicorn guarantees before the app does any work. ACA reported
ready: True,healthState: Healthyfor a replica that had answered zero requests.top on the same spool.
500 - The request timed outat 231s.events.jsonl.crash_recovery_respawn_limitdoes not help: it bounds phase 4 only, and the process neverreached phase 4. The
dead_letter_unparseablelines above are emitted fromqueue_manager.py:889, insiderecovery_seed_counts(phase 2).Fix — recovery still starts at startup, but it cannot gate serving
lifespan()now does only what must be true before serving (logging, drivers, schema), then:All four phases move into a new module-level
_startup_recovery(app). Startup completes in ~1s andthe HTTP surface opens regardless of spool size; recovery marches on behind it.
This is safe because respawn is idempotent by construction —
_crash_recovery_topupis alreadydocumented as safe to call repeatedly against a live server, and the sweep loop has always called it
on a serving process. The sweep is now
awaited inside the same task rather than spawned as asecond one, so there is one task to cancel and no way for it to outlive its parent. Cancellation
happens before
shutdown_workers(), so recovery cannot spawn drainers into a quiescing registry./statusgainsrecovery_complete, so a caller reading it mid-recovery is told the conservationcounters have not been seeded yet rather than reading in-flight numbers as settled.
Also: the untagged-
:Nodeboot guard is removedStartup no longer counts untagged nodes or refuses to boot on them. Two reasons:
neo4j_storeislabel-scoped — the node MERGE, both edge-endpoint MERGEs, and the session write path all
MERGE (n:Node {node_id, workspace}).get_node()/get_edge()became:Node-scoped(fix(neo4j): scope get_node's fallback MATCH to :Node so it seeks instead of scanning #98), read and write paths agree. A stray untagged node is inert dead data, not a correctness
hazard — and refusing to serve over inert data is disproportionate, especially in a change whose
whole purpose is that the server must come up.
The capability is not lost:
doctorstill reports untagged nodes viadiagnose()/count_untagged_nodes, anddoctor --fixstill repairs them. Detection moved to the operator path,where it belongs. (Historically this check was itself an
AllNodesScanthat caused a 25–30s bootstall until PR #67 made it O(1) — a second reason not to keep it on the startup path.)
Tests
await app.state.recovery_completeinside theasync with lifespan(...)block before asserting. Every existing assertion kept; only theobservation point moved.
main.count_untagged_nodesare deleted andreplaced by
test_lifespan_does_not_gate_boot_on_untagged_nodes, which asserts the guard isgenuinely gone (
hasattr(main_module, "count_untagged_nodes") is False) and boot completes withno untagged-count patching. Its docstring records why, so the next reader does not read the
deletion as an accident.
test_lifespan_calls_ensure_schema_with_fail_on_data_conflictdrops only thecount_untagged_nodespatch; allensure_schemaassertions unchanged.tests/neo4j/test_node_identity_migration.py— comment-only correction. That test never callslifespan(); it exercisesensure_neo4j_schema/count_untagged_nodesdirectly and isunaffected. Its stale "Step 2 (lifespan): the O(1) untagged guard DOES catch it" comments now
attribute the signal to
doctor/run_repair. No assertions or logic touched.No assertion was weakened.
Results:
pytest -m "not neo4j"→ 1980 passed, 7 skipped, 92 deselected;tests/test_main.py→ 85 passed.Follow-ups, deliberately not in this PR
GET /version, nottcpSocket:8000. That change onlybecomes meaningful once this PR guarantees the route answers — until then an HTTP probe would
fail during recovery and kill the container. Config, not code; land this first.
commit()writes only the.offset; the.logis unlinked solely by
delete_drained()on graceful finalization._all_worker_keys()globsevery
*.logand*.dead.jsonlwith no filter, so phases 1–2 re-walk fully-drained sessionson every boot. Recovery cost therefore grows with total historical sessions rather than with
pending work. Worth its own change.
Background-task failures fail loud
A background task's unhandled exception vanishes until garbage collection
("Task exception was never retrieved"). On the old inline path the same failure aborted boot
loudly; moving recovery off that path would have converted a loud failure into a silent one —
counters unseeded, no drainers respawned, behind a server that looks perfectly healthy.
So
_startup_recoveryis a thin wrapper around_startup_recovery_body: it re-raisesCancelledError(shutdown, not failure), and on any other exception recordsapp.state.recovery_error, logs vialogger.exception, and in afinallyalways setsrecovery_completeso/statuscannot report "in progress" forever./statusreportsrecovery_erroralongsiderecovery_complete— "complete" alone would be a lie by omissionwhen recovery failed.
Tests for the new guarantee
The reworked tests above prove recovery still works. These four prove the thing this PR
actually changes:
test_status_and_version_respond_while_recovery_is_still_runningGET /versionandGET /statusoverhttpx.ASGITransportwhile recovery is genuinely in flight — both 200,recovery_complete: false. Releases the gate and re-checks:recovery_complete: true,recovery_error: null.test_recovery_failure_is_logged_and_does_not_take_down_the_serverrecovery_error, is surfaced on/status, is logged at ERROR, and leaves the server serving.test_recovery_task_is_cancelled_on_shutdown_startup_recoverytask survives lifespan exit — a survivor would raceshutdown_workers()and dead-letter healthy events.test_status_reports_recovery_state_before_lifespan_has_run/statusdegrades gracefully (200,false/null) when the state attributes are absent, rather than raisingAttributeError.All four verified RED against
main's inline-recovery source (source reverted, tests kept),alongside the five reworked recovery tests and the untagged-guard replacement — 10 failures on
the old source, 89 passed on the new.
Final:
pytest -m "not neo4j"→ 1984 passed, 7 skipped, 92 deselected.