feat(phase-0): stability hardening — search path fix, auto-migrations, graceful shutdown, health endpoint
- Remove SET search_path from db.py and migration SQL (Supabase uses public schema) - Add migrate.py with tracking table for forward-only SQL migrations - Add entrypoint.sh: waits for DB, runs migrations, then starts app - Copy 01-schema.sql + zz-seed.sql to worker/src/migrations/ - Add health.py: /health endpoint (200/503) with DB connectivity + scheduler staleness checks - /stats endpoint with keyword/ad/notification counts - Rewrite main.py shutdown sequence: signal handler, 5s grace for scheduler, ordered cleanup - Update Dockerfile: HEALTHCHECK directive, entrypoint, COPY migrations - Update docker-compose.yml: stop_grace_period=15s, healthcheck config, env vars - Add aiohttp>=3.9 to requirements.txt for health server
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# Task: Graceful shutdown fix
|
||||
|
||||
## Description
|
||||
|
||||
The current `main.py` has a broken shutdown sequence. When the application receives a termination signal, calling `app.shutdown()` while the updater/scheduler are still active raises:
|
||||
|
||||
```
|
||||
RuntimeError: This Application is still running!
|
||||
```
|
||||
|
||||
This means:
|
||||
- DB connections may not be properly returned to the pool
|
||||
- In-flight Telegram messages may be lost
|
||||
- The container does not exit cleanly on SIGTERM, causing Kubernetes/Docker health issues during restarts
|
||||
|
||||
This task reorders the shutdown sequence and adds proper signal handling so the worker exits gracefully within 10 seconds.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Current (broken):
|
||||
SIGTERM → cleanup()
|
||||
├─ app.shutdown() ← FAILS: RuntimeError "still running"
|
||||
└─ close_pool() ← never reached or runs in bad state
|
||||
|
||||
Target (fixed):
|
||||
SIGTERM → cleanup()
|
||||
├─ scheduler_task.cancel() ← stop scraping loop
|
||||
├─ await asyncio.gather(scheduler_task) ← let it finish current iteration
|
||||
├─ app.updater.stop_polling() ← stop Telegram long-poll
|
||||
├─ app.shutdown() ← safe now, nothing running
|
||||
└─ close_pool() ← return all connections to pool
|
||||
```
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **Drain period**: After signal received, allow up to 5 seconds for the current scrape cycle and pending notifications to complete.
|
||||
- **Force kill fallback**: If cleanup does not finish in 10 seconds total, force-exit via `os._exit(0)`.
|
||||
- **asyncpg pool close** uses timeout (default 30s) — acceptable since we already cancelled tasks.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Current `main.py` shutdown code (problematic):
|
||||
|
||||
```python
|
||||
# Simplified current pattern
|
||||
try:
|
||||
# ... app.run() or equivalent
|
||||
except KeyboardInterrupt:
|
||||
app.shutdown() # ← RuntimeError here
|
||||
loop.run_until_complete(close_pool())
|
||||
```
|
||||
|
||||
### Target implementation:
|
||||
|
||||
Replace the signal handling block in `main.py` with:
|
||||
|
||||
```python
|
||||
import signal
|
||||
import sys
|
||||
|
||||
_scheduler_task: asyncio.Task | None = None
|
||||
|
||||
async def cleanup(app: Application) -> None:
|
||||
"""Gracefully shut down all components."""
|
||||
logger.info("Shutting down...")
|
||||
|
||||
# 1. Cancel scheduler task (with grace period)
|
||||
if _scheduler_task and not _scheduler_task.done():
|
||||
_scheduler_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(_scheduler_task, timeout=5.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
# 2. Stop polling
|
||||
if app.updater and app.updater.running:
|
||||
app.updater.stop_polling()
|
||||
|
||||
# 3. Now safe to shutdown the application
|
||||
await app.shutdown()
|
||||
|
||||
# 4. Close DB pool
|
||||
await close_pool()
|
||||
|
||||
logger.info("Shutdown complete.")
|
||||
|
||||
|
||||
async def main():
|
||||
global _scheduler_task
|
||||
|
||||
app = Application.builder().token(TOKEN).build()
|
||||
register_handlers(app)
|
||||
setup_global_commands(app)
|
||||
|
||||
# Start polling
|
||||
app.updater.start_polling(drop_pending_updates=True)
|
||||
|
||||
# Start scheduler
|
||||
_scheduler_task = asyncio.create_task(run_scheduler())
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(
|
||||
sig, lambda: asyncio.create_task(cleanup(app))
|
||||
)
|
||||
|
||||
# Wait until the app is done or cleanup finishes
|
||||
try:
|
||||
await _scheduler_task # runs forever until cancelled
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Final cleanup (in case signal handler already ran)
|
||||
await cleanup(app)
|
||||
```
|
||||
|
||||
### What changes in `main.py`:
|
||||
|
||||
1. **Remove** the broken try/except KeyboardInterrupt pattern
|
||||
2. **Add** the `cleanup()` async function with proper ordering
|
||||
3. **Add** signal handlers for SIGTERM and SIGINT via `loop.add_signal_handler`
|
||||
4. **Track** the scheduler task as a global so cleanup can reference it
|
||||
5. **Remove** any direct calls to `app.shutdown()` elsewhere
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Sending `docker stop willhaben-tracker-worker-1` results in clean exit (exit code 0) within 10 seconds
|
||||
- [ ] No `RuntimeError: This Application is still running!` appears in logs during shutdown
|
||||
- [ ] The asyncpg pool closes cleanly — no "connection closed unexpectedly" warnings after shutdown
|
||||
- [ ] In-flight notifications are allowed to complete (not dropped mid-send)
|
||||
- [ ] Restarting the container via `docker restart` works without errors
|
||||
- [ ] Logs show clear shutdown sequence: "Shutting down..." → "Shutdown complete."
|
||||
Reference in New Issue
Block a user