Skip to content

fix(data-warehouse): stop deleted schemas and dead lock holders from wedging the duckgres planner - #69103

Merged
EDsCODE merged 3 commits into
masterfrom
fix/duckgres-planner-deleted-schemas-lock-timeout
Jul 9, 2026
Merged

fix(data-warehouse): stop deleted schemas and dead lock holders from wedging the duckgres planner#69103
EDsCODE merged 3 commits into
masterfrom
fix/duckgres-planner-deleted-schemas-lock-timeout

Conversation

@EDsCODE

@EDsCODE EDsCODE commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two resource leaks in the duckgres backfill planner, both of the "quietly wedge the fleet" shape:

  1. Soft-deleted schemas permanently consume backfill slots. Deleting a source soft-deletes its schemas, but nothing tells the sink state machine. The eligibility gate makes a deleted schema's chunks unclaimable, so its BACKFILLING row sits forever - zero failures, invisible on every gauge - while _global_at_capacity counts it against MAX_CONCURRENT_BACKFILLS_GLOBAL = 5. Five routine deletions halt all backfill planning for every org. The reconciler also re-enqueues the orphaned chunks after each 7-day queue prune, and _plan_one reads schemas through the default manager, which includes soft-deleted rows, so a deleted schema could still be planned fresh.
  2. A dead advisory-lock holder blocks every pod indefinitely. enqueue_chunks serializes on a session pg_advisory_lock with no lock_timeout, on a connection with no keepalives. A pod that dies without FIN leaves the server session (and its lock) alive for hours; every other pod's reconciler replay then blocks on the lock inline in the consumer's fetch path - the whole duckgres sink stops claiming, pod by pod. Same failure shape as a previous fleet-wide delta-loader stall.

Changes

  • New _purge_deleted_schema_states sweep in the reconciler: retires the runs of deleted schemas and drops their state rows (bootstrap re-creates PENDING_BACKFILL if a schema is ever restored)
  • _plan_one skips deleted schemas instead of planning them
  • enqueue_chunks bounds the advisory-lock wait at 10s - on timeout it skips the replay for this tick and logs; the next tick retries once the dead session is reaped
  • The planner's three queue-DB connections get connect_timeout + keepalives, matching the duckgres connection's bounded-liveness rationale

How did you test this code?

I (Claude) added four tests to test_backfill.py, each catching a regression no existing test covered:

  • The purge retires the run and drops the state row for a deleted schema (this is the capacity-slot leak)
  • The purge leaves live schemas untouched
  • _plan_one refuses to plan a deleted schema (the default-manager hole)
  • A lock timeout in enqueue_chunks skips the replay, resets lock_timeout, and inserts nothing

pytest .../duckgres/test_backfill.py: 15 passed.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Deep QA session on the duckgres consumer (Claude Code). The deleted-schema leak came from an adversarial state-machine review ("what happens when a user deletes a source mid-backfill"); the unbounded lock from a reliability review that pattern-matched it to a prior fleet stall. I chose deleting the state row over a terminal state value to keep the state machine's surface unchanged; the tradeoff (bootstrap re-primes a restored schema from scratch) is the already-correct behavior for a table whose duckgres copy may have aged out anyway.

…wedging the duckgres planner

Two planner resource leaks found in a deep QA pass:

Soft-deleted schemas: deletion has no signal into the sink state machine.
A deleted schema's chunks are unclaimable (the eligibility gate excludes
deleted schemas), so its BACKFILLING row sat forever with zero failures on
any gauge while consuming one of the MAX_CONCURRENT_BACKFILLS_GLOBAL slots
— five routine source deletions would halt all backfill planning fleet-wide
— and the reconciler re-enqueued its chunks after every queue prune. The
reconciler now retires such runs and drops the state rows (bootstrap
re-creates PENDING_BACKFILL if a schema comes back), and _plan_one skips
deleted schemas instead of planning them through the default manager.

Advisory-lock waits: enqueue_chunks took a session advisory lock with no
lock_timeout on a connection with no keepalives. A pod dying without FIN
leaves the server session (and lock) alive for hours, and every other pod's
reconciler replay then blocks indefinitely — inline in the consumer fetch
path, halting all duckgres claiming, the same shape as the July delta-loader
fleet stall. The lock wait is now bounded at 10s (timeout skips the replay
for one tick), and the planner's queue connections get connect_timeout and
keepalives matching the duckgres connection's rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@EDsCODE EDsCODE self-assigned this Jul 7, 2026
@trunk-io

trunk-io Bot commented Jul 7, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

@EDsCODE
EDsCODE marked this pull request as ready for review July 9, 2026 17:50
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 9, 2026 17:51
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mas..." | Re-trigger Greptile

Comment on lines +224 to 235
with psycopg.connect(
settings.WAREHOUSE_SOURCES_DATABASE_URL,
autocommit=True,
# These run inline in the consumer fetch path: a half-open connection
# must fail in minutes, not the OS TCP timeout (hours).
connect_timeout=30,
keepalives=1,
keepalives_idle=60,
keepalives_interval=15,
keepalives_count=4,
) as conn:
retire_backfill_run(conn, run_uuid=state.backfill_run_uuid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The same five psycopg keepalive/timeout kwargs are copy-pasted across replan_backfill, _plan_one, and _reconcile. A single module-level dict extracted once would satisfy the OnceAndOnlyOnce rule and make future tuning a one-line change.

Suggested change
with psycopg.connect(
settings.WAREHOUSE_SOURCES_DATABASE_URL,
autocommit=True,
# These run inline in the consumer fetch path: a half-open connection
# must fail in minutes, not the OS TCP timeout (hours).
connect_timeout=30,
keepalives=1,
keepalives_idle=60,
keepalives_interval=15,
keepalives_count=4,
) as conn:
retire_backfill_run(conn, run_uuid=state.backfill_run_uuid)
with psycopg.connect(
settings.WAREHOUSE_SOURCES_DATABASE_URL,
autocommit=True,
**_PLANNER_CONNECT_KWARGS,
) as conn:
retire_backfill_run(conn, run_uuid=state.backfill_run_uuid)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +159 to +168
conn.execute("SET lock_timeout = '10s'")
try:
conn.execute(
"SELECT pg_advisory_lock(%s, hashtext(%s))",
[BACKFILL_ENQUEUE_LOCK_NAMESPACE, run_uuid],
)
except psycopg.errors.LockNotAvailable:
conn.execute("RESET lock_timeout")
logger.warning("duckgres_backfill_enqueue_lock_timeout", run_uuid=run_uuid)
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SET lock_timeout = '10s' is a session-level setting, so it remains active for the entire second try block that follows — covering all the INSERT INTO and SELECT pg_advisory_unlock calls, not just the advisory lock acquisition. If a row-level lock wait on an INSERT exceeds 10 s, PostgreSQL raises LockNotAvailable too. The finally block still runs (releasing the advisory lock and calling RESET lock_timeout), so cleanup is correct, but the partial-insertion failure propagates to the caller. Since partial insertion is safe (idempotency via chunk.index in existing), the behaviour is fine on the next tick — but the intent reads as "bound only the advisory-lock wait", and a reader might not realise the timeout is wider. Consider narrowing the scope with RESET lock_timeout immediately after the advisory lock is acquired.

@EDsCODE
EDsCODE merged commit 162a11f into master Jul 9, 2026
263 of 267 checks passed
@EDsCODE
EDsCODE deleted the fix/duckgres-planner-deleted-schemas-lock-timeout branch July 9, 2026 18:54
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-09 19:24 UTC Run
prod-us ✅ Deployed 2026-07-09 19:39 UTC Run
prod-eu ✅ Deployed 2026-07-09 19:41 UTC Run

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.

2 participants