feat: bound crash-recovery respawn, expose spool depth, make worker timeouts configurable - #73
Conversation
…imeouts configurable
MOTIVATING INCIDENT
On a real host install, the durable queue spool grew to 38 GB / 583 files
(largest 4.9 GB) with no visible signal anywhere. On restart, lifespan
startup logged 'crash recovery respawned 94/94 drainers', took ~4 minutes,
peaked at 43.9 GB RSS, and was SIGKILLed by the kernel OOM killer. systemd
restarted it (Restart=on-failure, no start limit), and the cycle repeated
— 16 worker boots per 30 minutes for two days, serving zero requests. The
backlog could never shrink because every boot repeated the same unbounded
respawn. The only visible symptom was a graph that silently stopped
updating; nobody noticed for two days.
CHANGES (all opt-in)
1. Bounded crash-recovery respawn
- New crash_recovery_respawn_limit: int | None = None in config
- Lifespan respawn loop now slices recovered[:limit] when set
- Default None preserves today's unbounded behaviour bit-for-bit
- Deferred sessions are never touched (no read, no write, no drainer)
- Logs at WARNING (not INFO) with exact respawned/deferred counts
- Deferred backlog never silent since that silence hid the 38 GB spool
2. Spool depth on /status
- New QueueManager.spool_stats() — stat-only directory scan
- 5s cached (separate from derive_all_stats's 1s cache)
- Safe under frequent polling even with thousands of files
- Returns exactly {"pending_sessions": int, "spool_bytes_total": int}
- /status is unauthenticated, honours existing aggregate-only contract
- Two integers, no session ids, no workspace names
- Silent two-day outage becomes five-minute one
3. Configurable gunicorn timeouts
- gunicorn_worker_timeout: int = 30
- gunicorn_graceful_timeout: int = 10
- Replace hardcoded literals in run()
- Defaults exactly match previous values
- Server with O(backlog) startup can accommodate it
VERIFICATION
- uv sync && uv run pytest tests/ -q: 1913 passed, 4 skipped, 1 error in 137s
- Single error is pre-existing & environmental: tests/integration/test_docker_image.py
502 Bad Gateway from pythonhosted.org during container install. Touches none of
changed files.
- New tests cover: unbounded-by-default, cap enforced with warning and counts,
deferred sessions verified intact and recoverable on subsequent boot, cap=0
edge case, /status spool block present and typed, reflects real backlog, leaks
no session ids or workspace names; spool_stats() byte/session counting,
exclusion of committed/dead-letter-only sessions, TTL caching; gunicorn
timeouts defaulting and overriding end-to-end through run().
- python_check clean on all 7 files (pre-existing warnings confirmed).
Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… the deferred backlog, bound recovery memory Builds on PR #73 to fully deliver its intent — a more robust/reliable durable-queue server on Azure Container Apps — by closing gaps that left PR #73's own mechanisms incomplete. All changes are no-ops on the default (crash_recovery_respawn_limit=None) path; existing deployments are unaffected. 1. /status never 500s the ACA health probe. spool_stats() used iterdir() (raises on a missing/unavailable queue dir) where every sibling reader uses glob() (empty, no raise), and its offset read was unguarded — an uncaught exception on the unauthenticated /status health endpoint = failed probe = container restart loop. Now degrades to a sentinel {-1,-1,-1} on any directory-level/transient FS error (not cached, self-heals) and skips a per-file corrupt offset. (ci_pr73-ueh) 2. Corrupt .offset no longer 500s /status; it is surfaced, not logged. derive_all_stats() (which /status hits before spool_stats) now degrades a corrupt/unreadable offset to committed=0 instead of raising, and spool_stats() exposes an aggregate spool.corrupt_offsets count — zero log noise on the polled health path, operator watches the field. (ci_pr73-267) 3. A finite crash_recovery_respawn_limit no longer permanently strands the deferred backlog. recover() ran only at boot, so a finite cap (or cap=0) drained the head and left the tail until a restart/new event — for completed sessions, never. Added a bounded periodic sweep (new crash_recovery_sweep_interval_seconds=300, active only under a finite ceiling) that tops the drainer pool back up to the ceiling; idempotent get_or_create keeps live recovered drainers <= the ceiling while the tail advances. Documented all three previously-undocumented settings in server-config.example.yaml. (ci_pr73-rt7) 4. Bounded recovery/stats memory — the actual OOM cause. recovery_seed_counts(), _complete_data_end(), and _count_dead() read whole (multi-GB) spool files into RAM via read_bytes() before the cap could apply. Replaced with bounded 1 MiB streaming (backward-scan for last newline; chunked newline counts) — numerically identical, O(chunk) memory. Measured on a 1 GiB log: peak RSS 1559 MB -> 37 MB. (ci_pr73-xq2, ci_pr73-n4h) Verification: uv run pytest -m "not neo4j" -> 1856 passed, 4 skipped. uv run pyright clean on changed source. Live gunicorn server: /status returns 200 under normal / corrupt-offset / missing-dir / recovery. Launched in an isolated Incus DTU: boots single-worker without Neo4j and serves /status 200 with the spool block. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Added hardening commit
|
…boot Rebase-integration fixes required where PR #70 (data-quality phase 2) and the work merged to main after it (PR #72/#73) touched the same lifespan region: - main.py: seed _sweep_task=None and queue_health="healthy" before the B1 deploy-safe boot boundary. The #73 periodic crash-recovery sweep task and the W-2 queue-health signal are both created INSIDE that boundary, so they must be seeded before it or the shutdown finally / a startup failure hits an unbound name. queue_health default is "healthy" per PR #70's W-2 contract (test_queue_recovery_success_leaves_queue_health_healthy). - test_queue_manager.py / test_main.py: update 5 QueueManager.commit() call sites in PR #73's tests to PR #70 I5b's 3-arg signature commit(sid, offset, cursor) (cursor has no default by design, spec 10.4). Cursor=None: these are queue-level tests, not cursor-durability tests. Preserves both feature sets: #73 bounded respawn + spool + sweep, and #70 deploy-safe boot + maintenance gate + W-2 queue health. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Summary
Three opt-in changes to fix crash-recovery runaway and add observability to the durable queue.
Motivating incident (real, measured on a host install):
crash recovery respawned 94/94 drainersChanges
1. Bounded crash-recovery respawn
crash_recovery_respawn_limit: int | None = Nonerecovered[:limit]when finite limit is setNonepreserves today's unbounded behaviour bit-for-bit — this PR is a no-op for any deployment that sets nothingrecover()reports deferred sessions again; a new event spawns their drainer viaget_or_create()recoveredis already sorted, so deferred sessions deterministic across restarts2. Spool depth on
/statusQueueManager.spool_stats()— stat-only directory scan (never reads file content)derive_all_stats's 1s cache) — safe under frequent polling even with thousands of files{"pending_sessions": int, "spool_bytes_total": int}/statusis unauthenticated, honours existing aggregate-only contract: two integers, no session ids, no workspace names3. Configurable gunicorn timeouts
gunicorn_worker_timeout: int = 30,gunicorn_graceful_timeout: int = 10run()Verification
The single error is pre-existing and environmental:
tests/integration/test_docker_image.pyperforms a realdocker buildNew tests cover:
cap=0/statusspool block present and correctly typedspool_stats(): byte/session counting, exclusion of fully-committed and dead-letter-only sessions, TTL cachingrun()python_checkclean on all 7 files (pre-existing warnings confirmed by diffing againstgit show HEAD:)Maintainer Note
Change 1 alters startup semantics only when explicitly opted into. The maintainer may reasonably want to discuss whether the default should eventually become finite — that discussion is deliberately deferred by keeping it unbounded here, making this PR safe to merge without changing any existing deployment's behaviour.
Files Changed
context_intelligence_server/config.py— config optionscontext_intelligence_server/main.py— lifespan respawn,/statusstatscontext_intelligence_server/queue_manager.py—spool_stats()tests/test_config.py— config teststests/test_main.py—/statusand respawn teststests/test_queue_manager.py—spool_stats()teststests/test_run_entrypoint.py— gunicorn timeout testsGenerated with Amplifier