perf(scheduler): index soft-deleted schedules for the cleaning daemon - #6924
perf(scheduler): index soft-deleted schedules for the cleaning daemon#6924pfreixes wants to merge 2 commits into
Conversation
The schedules hard-delete filters on deleted_at but orders by id, so the planner walked schedules_pkey filtering as it went: 256444 rows removed by filter and 253987 buffers to return a single row, 427ms per call, every ~8s. Indexed on id rather than deleted_at because the query's ORDER BY is on id; a deleted_at index would have to gather every eligible row and sort it. The partial predicate keeps the index to the soft-deleted population, and since neither id nor deleted_at changes on the hot next_execution_at updates, HOT updates are preserved and the index is untouched by them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 1 file
Confidence score: 3/5
- In
packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts, creating the index concurrently withIF NOT EXISTScan leave an invalid same-name index after an interrupted build, so later boots may skip rebuilding andhardDeleteOlderThanNDayscan run without the intended index; add validity checks/rebuild logic (or drop-invalid-then-create) so startup self-heals this state. - The down migration in
packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.tscan take anACCESS EXCLUSIVElock onschedules, which may block scheduler reads/writes and turn rollback into an outage under long-lived transactions; switch to a rollback path that avoids long exclusive locking (for example, concurrent-safe drop strategy).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts">
<violation number="1" location="packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts:11">
P1: An interrupted concurrent build can leave this migration permanently applied with an invalid index: the next scheduler boot sees the same-name relation, `IF NOT EXISTS` skips the build, and `hardDeleteOlderThanNDays` continues scanning live rows. A retry path that checks `pg_index.indisvalid`/`indisready` and drops an invalid same-name index concurrently before creating it would make interrupted deployments recover automatically.</violation>
<violation number="2" location="packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts:18">
P2: Rolling back this migration can block scheduler reads and writes while PostgreSQL acquires an `ACCESS EXCLUSIVE` lock on `schedules`, potentially turning a rollback into an outage under long-lived transactions. Using `DROP INDEX CONCURRENTLY IF EXISTS` is consistent with the concurrent index build and keeps rollback non-blocking.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| export async function up(knex: Knex): Promise<void> { | ||
| await knex.raw( | ||
| `CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_schedules_soft_deleted_id" |
There was a problem hiding this comment.
P1: An interrupted concurrent build can leave this migration permanently applied with an invalid index: the next scheduler boot sees the same-name relation, IF NOT EXISTS skips the build, and hardDeleteOlderThanNDays continues scanning live rows. A retry path that checks pg_index.indisvalid/indisready and drops an invalid same-name index concurrently before creating it would make interrupted deployments recover automatically.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts, line 11:
<comment>An interrupted concurrent build can leave this migration permanently applied with an invalid index: the next scheduler boot sees the same-name relation, `IF NOT EXISTS` skips the build, and `hardDeleteOlderThanNDays` continues scanning live rows. A retry path that checks `pg_index.indisvalid`/`indisready` and drops an invalid same-name index concurrently before creating it would make interrupted deployments recover automatically.</comment>
<file context>
@@ -0,0 +1,19 @@
+
+export async function up(knex: Knex): Promise<void> {
+ await knex.raw(
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_schedules_soft_deleted_id"
+ ON ${SCHEDULES_TABLE} USING BTREE (id)
+ WHERE deleted_at IS NOT NULL;`
</file context>
| } | ||
|
|
||
| export async function down(knex: Knex): Promise<void> { | ||
| await knex.raw(`DROP INDEX IF EXISTS "idx_schedules_soft_deleted_id";`); |
There was a problem hiding this comment.
P2: Rolling back this migration can block scheduler reads and writes while PostgreSQL acquires an ACCESS EXCLUSIVE lock on schedules, potentially turning a rollback into an outage under long-lived transactions. Using DROP INDEX CONCURRENTLY IF EXISTS is consistent with the concurrent index build and keeps rollback non-blocking.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/scheduler/lib/db/migrations/20260728071506_schedules_soft_deleted_index.ts, line 18:
<comment>Rolling back this migration can block scheduler reads and writes while PostgreSQL acquires an `ACCESS EXCLUSIVE` lock on `schedules`, potentially turning a rollback into an outage under long-lived transactions. Using `DROP INDEX CONCURRENTLY IF EXISTS` is consistent with the concurrent index build and keeps rollback non-blocking.</comment>
<file context>
@@ -0,0 +1,19 @@
+}
+
+export async function down(knex: Knex): Promise<void> {
+ await knex.raw(`DROP INDEX IF EXISTS "idx_schedules_soft_deleted_id";`);
+}
</file context>
Every scheduler migration since 2025-05 leaves down() empty; only the 2024-era ones carry rollback SQL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — both looked at. The reviewed commit was #2 (P2) — #1 (P1) — invalid index + The mechanism is right, with one correction: an interrupted build does not leave the migration applied. knex records a migration only on success, so a pod killed mid-build writes no row. It is the next boot that skips via Not fixing it in this file, for three reasons:
|
Why
The cleaning daemon's schedules hard-delete burns ~427 ms of CPU every ~8s to delete at most one row. Measured on cloud prod:
The query filters on
deleted_atbut orders byid, so the planner walksschedules_pkeyinidorder filtering as it goes. It estimatedrows=18and gambled on hitting a match early; it stepped over 256,444 live schedules instead. That is ~1.94 GiB of buffer accesses to return a single row, entirelyshared hit, so it is pure CPU on a fully cached table — roughly 6% of a core, permanently.It also degrades over time: as older deleted schedules get cleaned, the front of the
idordering fills with live rows and the walk gets longer.How
id, notdeleted_at. The query'sORDER BYis onid, so adeleted_atindex would have to gather every eligible row and sort it to pick one. A partial index onidwalks inidorder over soft-deleted rows only and stops at the first eligible one.schedulescurrently holds 2,578 soft-deleted rows (111 of them eligible) against 256k+ live rows, so the walk is bounded by 2,577 in the worst case however the ids interleave — a 100x reduction even pathologically.scheduleNextExecutionupdatesschedules~67/s. Neitheridnordeleted_atchanges on those updates, so HOT updates are preserved and this index is never touched by them. It is only written when a schedule is actually soft-deleted, which is rare.LIMIT 1and its cascade-safety reasoning stay exactly as they are. This is wasted scanning, not a backlog.CREATE INDEX CONCURRENTLYmanually, then inserts the migration row so the auto-runner skips it. The scheduler migration runs inline at orchestrator boot (app.tscallsdbClient.migrate()before the server starts) and knex's migration lock serializes every rolling pod behind it, so a slow build stalls the deploy. Worse, if a startup probe kills the pod mid-build, Postgres leaves an invalid index and the next boot'sIF NOT EXISTSmatches on name, silently skips, and marks the migration applied — a green deploy with no speedup and nothing pointing at why. Self-hosted auto-runs it on deploy, which is fine on their small tables.Commands
(Schema-qualified for the operator runbook — the migration file itself uses unqualified table names and relies on
search_path.)1. Pre-flight — confirm the recorded migration name format, and that nothing long-lived is open (
CONCURRENTLYwaits for every transaction older than each of its two phases):2. Build the index — CONCURRENTLY, no transaction wrapper (CONCURRENTLY can't run inside a txn):
3. Monitor progress (from a separate psql session while the build is running):
4. Verify success —
indisvalidandindisreadymust both bet:5. Mark migration applied — skip the auto-runner on next deploy:
6. Confirm the win — re-run the cleanup query's plan:
Emergency stop (if the build needs to be aborted mid-flight)
1. Find the PID (from a separate psql session):
2. Cancel gracefully (preferred):
If cancel doesn't take effect within ~30s (some CREATE INDEX phases ignore SIGINT), escalate to terminate (drops the connection):
3. Clean up the invalid leftover — cancelled/terminated builds leave an invalid index in the catalog:
After the DROP the table is back to a clean state and the build can be retried. Note this also matters if step 5 is skipped: an invalid index plus an unrecorded migration means the next deploy's
IF NOT EXISTSmatches the invalid index by name and no-ops.What
idx_schedules_soft_deleted_idonschedules (id) WHERE deleted_at IS NOT NULL, serving thehardDeleteOlderThanNDayscleanup query inpackages/scheduler/lib/models/schedules.ts.Test plan
npm run test:integration --dir=packages/scheduler— 85/85 pass, migration applies to the test databaseEXPLAIN (ANALYZE, BUFFERS)CREATE INDEX CONCURRENTLYand confirmindisvalid = t, indisready = tINSERT INTO nango_scheduler.migrationsto mark appliedRows Removed by Filterdrops from 256,444 to ~0