Skip to content

feat: bound crash-recovery respawn, expose spool depth, make worker timeouts configurable - #73

Merged
Diego Colombo (colombod) merged 2 commits into
mainfrom
feat/bound-queue-recovery-and-spool-visibility
Aug 17, 2026
Merged

feat: bound crash-recovery respawn, expose spool depth, make worker timeouts configurable#73
Diego Colombo (colombod) merged 2 commits into
mainfrom
feat/bound-queue-recovery-and-spool-visibility

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

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):

  • Durable queue spool grew to 38 GB / 583 files (largest 4.9 GB) with no visible signal
  • On restart: lifespan startup logged crash recovery respawned 94/94 drainers
  • Took ~4 minutes, peaked at 43.9 GB RSS
  • Killed by kernel OOM killer; systemd restarted repeatedly
  • 16 worker boots per 30 minutes for two days, serving zero requests
  • Backlog could never shrink (cycle repeated every boot)
  • Only symptom: graph silently stopped updating; nobody noticed for two days

Changes

1. Bounded crash-recovery respawn

  • New config option: crash_recovery_respawn_limit: int | None = None
  • Lifespan respawn loop now slices recovered[:limit] when finite limit is set
  • Default None preserves today's unbounded behaviour bit-for-bit — this PR is a no-op for any deployment that sets nothing
  • Deferred sessions are never touched (no read, no write, no drainer) — they remain exactly as durable and recoverable as before
  • Later boot's recover() reports deferred sessions again; a new event spawns their drainer via get_or_create()
  • recovered is already sorted, so deferred sessions deterministic across restarts
  • When cap trips: logs at WARNING (not INFO) with exact respawned/deferred counts and setting name
  • Deferred backlog never silent — silence is what hid the 38 GB spool

2. Spool depth on /status

  • New QueueManager.spool_stats() — stat-only directory scan (never reads file content)
  • 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
  • Impact: silent two-day outage becomes five-minute one

3. Configurable gunicorn timeouts

  • New config options: gunicorn_worker_timeout: int = 30, gunicorn_graceful_timeout: int = 10
  • Replace hardcoded literals in run()
  • Defaults exactly match previous values (no behaviour change)
  • Server whose startup is O(backlog) can now accommodate that

Verification

uv sync && uv run pytest tests/ -q
→ 1913 passed, 4 skipped, 1 error in 137s

The single error is pre-existing and environmental:

  • tests/integration/test_docker_image.py performs a real docker build
  • Hit transient 502 Bad Gateway from pythonhosted.org during container install
  • Touches none of the changed files
  • Flagged for context rather than omitted

New tests cover:

  • Unbounded-by-default behavior
  • Cap enforced with warning and exact respawned/deferred counts
  • Deferred sessions verified intact and recoverable on subsequent boot
  • Edge case: cap=0
  • /status spool block present and correctly typed
  • Reflects real backlog; leaks no session ids or workspace names
  • spool_stats(): byte/session counting, exclusion of fully-committed and dead-letter-only sessions, TTL caching
  • Both gunicorn timeouts: defaulting and overriding end-to-end through run()

python_check clean on all 7 files (pre-existing warnings confirmed by diffing against git 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 options
  • context_intelligence_server/main.py — lifespan respawn, /status stats
  • context_intelligence_server/queue_manager.pyspool_stats()
  • tests/test_config.py — config tests
  • tests/test_main.py/status and respawn tests
  • tests/test_queue_manager.pyspool_stats() tests
  • tests/test_run_entrypoint.py — gunicorn timeout tests

Generated with Amplifier

…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>
@colombod

Copy link
Copy Markdown
Collaborator

Added hardening commit 3ace10e — in service of this PR's goal

Hi Brian Krabach (@bkrabach) — I pushed one commit on top of this branch to make PR #73's own promise (robust/reliable durable queue on ACA) hold end-to-end. It's a no-op on the default path (crash_recovery_respawn_limit=None); every existing deployment is unaffected. It closes four gaps where the current PR's mechanisms were incomplete:

  1. /status must never 500 — it's 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. On the unauthenticated /status, an uncaught exception = failed probe = container restart loop. It now degrades to a sentinel {-1,-1,-1} (not cached, self-heals) and skips a per-file corrupt offset.

  2. A corrupt .offset also 500'd /status via derive_all_stats() (runs before spool_stats in get_status) — pre-existing on main. It now degrades that offset to committed=0 and surfaces the condition as an aggregate spool.corrupt_offsets count. Deliberately no logging (the probe is polled — a per-scan warning would flood); operators watch the field.

  3. A finite crash_recovery_respawn_limit permanently stranded the deferred backlog. recover() runs only at boot, so a finite cap (or cap=0) drains the head and leaves the tail until a restart or a new event — for completed sessions, never. Added a bounded periodic sweep (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. All three new settings are now documented in server-config.example.yaml.

  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 — so the cap capped the respawn loop but not the dominant boot cost. Replaced with 1 MiB streaming (backward-scan for last newline; chunked newline counts), numerically identical.

Evidence

  • No regression: uv run pytest -m "not neo4j"1856 passed, 4 skipped; pyright clean on changed source.
  • Memory: 1 GiB log, isolated processes — peak RSS 1559 MB → 37 MB, identical counts (before,pending,dead). Extrapolated to the incident's 4.9 GB file: ~7.5 GB → ~37 MB.
  • Health probe: real gunicorn+uvicorn single worker — /status returns 200 under normal / corrupt-offset / missing-queue-dir / recovery.
  • Sweep: cap=2, 5 recoverable sessions → all drain across bounded passes (≤ cap live at once), deferred tail not stranded.
  • Deployment: launched in an isolated Incus environment — boots single-worker without Neo4j (tolerated) and serves /status 200 with the spool block.

Happy to split any of these into separate commits, reword, or drop #3's default-on sweep to opt-in if you'd prefer — your call on the merge shape.

@colombod
Diego Colombo (colombod) merged commit b5750b1 into main Aug 17, 2026
3 checks passed
Diego Colombo (colombod) added a commit that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants