Instruction file imported from jleist-clemson/acheron (
.cursor/rules/background-tasks.mdc). Copyright stays with the author.
Background Task Lifecycle
Background tasks (WorkerPool, EsIndexer, RollupScheduler) follow a
start() / stop() shape with an asyncio.Event stop signal and a single
asyncio.create_task(..., name=...). The skeleton matters less than the
decisions below — get these wrong and you lose data or hang a deploy.
Drain vs. cancel on stop() (a correctness decision)
- If the task holds un-acked, un-persisted work, drain it before cancelling.
WorkerPool.stop()doesawait queue.join()with a timeout, then cancels. - If the task's work is idempotent / replayable (the outbox re-indexes, the
next tick re-aggregates), set the stop event and cancel directly —
EsIndexer/RollupScheduler.
When adding a task, ask: would cancelling mid-flight lose anything? That answer picks the variant.
Loop invariants
- Idle interruptibly — never block shutdown for a full interval:
# ✅ wakes immediately on stop
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=self._interval)
except asyncio.TimeoutError:
pass # interval elapsed
# ❌ asyncio.sleep(self._interval) # ignores the stop signal
- The loop body catches and logs, never lets an exception kill the task.
- Consumers must call
task_done()for everyget(), even on error.
Shutdown ordering in app/main.py lifespan
ingestion.stop_accepting() → worker.stop() (drain) → es_indexer.stop() →
rollup.stop() → then close Mongo/ES/Redis clients. Never close a client a
running task still uses.