25d8655ad455f4845345c72eee9b4d5dff6be5cd
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1fcfd47ab6 |
fix(startup): a slow database costs seconds, not the instance (#4181)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 26s
On 2026-09-19 a host storage stall made one Postgres checkpoint of 14 buffers take 281 seconds against a 1.3-second baseline. The app restarted into the tail of it, `get_maintenance_hour()` — the first DB read in `before_serving` — hung with no deadline, Hypercorn killed the worker at its 60-second lifespan timeout, and nothing retries a failed lifespan. A five-minute disk hiccup became a three-hour outage that only a human restart could clear. Every MCP call returned 405, which reads like a routing fault and was nothing of the kind: nothing was serving. Three changes, none of which prevent a stall — they stop a transient one becoming a permanent one. 1. THE STARTUP READ IS BOUNDED (rule 156). `get_maintenance_hour` already answered `_DEFAULT_HOUR` for a value it could not parse; a database that will not answer in three seconds is the same class of "no usable value here". The failure is now a WARNING naming the symptom — the breadcrumb whose absence meant this was only diagnosable from Postgres's own log — and a default run-hour, instead of the app. 2. THE BACKFILL NO LONGER RACES STARTUP. Its comment said it "never blocks the server from accepting requests": true of requests, false of startup, because the task began while `before_serving` was still running and competed for the same pool. Both of the incident's cancelled statements were in flight together. It now waits on a flag released on the hook's way out — in a `finally`, never after the work (rule 157), because an undeadlined wait is only safe when the wake-up cannot be missed. 3. THE ENGINE CANNOT WAIT FOREVER TO CONNECT. asyncpg's default is 60s, the whole lifespan budget spent before a query is sent. `command_timeout` is deliberately NOT set alongside it and the comment says why: it would apply to every statement, and this app runs long ones on purpose. tests/test_startup_survives_a_slow_database.py asserts the shape rather than the stall: a read that never returns still yields an hour, the warning names the symptom, a healthy read is unaffected, the backfill does no work before release, the flag is released even when startup raises, and the engine's connect args carry a deadline but no blanket statement timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
7d48eb0b1b |
refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled while-True/sleep/try loops in logging, auth and notifications. - services/scheduler.ScheduledJob replaces the four private BackgroundScheduler copies in recurrence/version_pinning/trash/db_maintenance schedulers; public start_/stop_/reschedule_ surfaces unchanged. - api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x. - auth.is_registration_open reads via settings.get_admin_setting; notification prefs read via settings.get_setting; _fire_share_email uses _get_user_email. - projects.get_project_summary / milestones.get_project_milestone_summary are now the one-id view of their batch siblings instead of a second copy of the queries; sharing.best_permission_by (was _deduplicate_by_permission) is the one rank-dedup, now also used by list_projects_for_user. - backup: the row builders for every section both exporters carry are named functions, so a column added to one export cannot silently miss the other. - iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom and db_maintenance._iso across services; backup keeps its explicit shape. - trash.py hoists the sql_delete/timedelta imports it re-imported per function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c4553d937c |
feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
Adds a daily off-hours VACUUM (ANALYZE) over the high-churn tables the retention/purge sweeps churn (app_logs, notifications, token tables, notes, note_versions), on top of Postgres autovacuum, to reclaim bloat left by the nightly bulk DELETEs and keep planner stats fresh. - services/db_maintenance.py: run_maintenance() over a closed table allowlist via an AUTOCOMMIT connection (VACUUM can't run in a txn); per-table summary persisted as the db_maintenance_last_run admin setting. - services/db_maintenance_scheduler.py: BackgroundScheduler cron (default 04:00 UTC, after the 03:30 trash purge); enabled-gate checked at fire time; live reschedule on hour change. Wired into app.py start/stop. - routes/admin.py: admin-only GET/PUT /api/admin/db-maintenance + POST /run. - settings.py: set_admin_setting() (write-side of get_admin_setting) for out-of-request writes. - SettingsView.vue: admin 'Database maintenance' card — enable toggle, run-hour (UTC), Run-now, last-run summary. - Tests: allowlist is closed, VACUUM issued per table, one failure doesn't abort the rest, summary persisted; route/scheduler/service surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |