perf(data-warehouse): reuse the duckgres session across a group's live batches#73028
perf(data-warehouse): reuse the duckgres session across a group's live batches#73028EDsCODE wants to merge 6 commits into
Conversation
…e batches Production phase attribution (2026-07-22) showed a live batch's ~30-40s apply is almost entirely per-SESSION fixed cost, not row volume: worker session create (~0.5-1s), the extended-protocol describe probe's cold catalog enumeration (~5-19s, measured +5.3s cold vs warm), and the transaction's first-write metadata touch on the target table (~12-19s) — while the MERGE itself runs in ~80-100ms and the commit fits in <0.7s. The sink opened a fresh connection per batch, paying all of it every 5,000 rows. Cache the connection per (team_id, schema_id) — the group lease already serializes each key to one pod task, so entries are never used concurrently. Preamble (extensions + extract-read secret) runs once per session; entries drop on any processing error (aborted-txn state), after 90s idle, and at a 600s absolute age cap so the embedded session credentials never outlive their validity across steady reuse. Backfills keep the fresh-connection lifecycle. With the co-claim fix delivering consecutive batches in one group hold, warm batches drop from ~30-40s to ~2s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
| _DuckgresSessionCache._close_quietly(conn) | ||
| raise | ||
| else: | ||
| _session_cache.store(batch.team_id, batch.schema_id, conn, created_at=session_created_at) |
There was a problem hiding this comment.
Bound cached Duckgres sessions by the org connection budget
store() retains a separate connection for every (team_id, schema_id) but never closes it when its 90-second TTL elapses unless another cache operation happens. This bypasses the queue's sink_max_concurrency connection cap: an org user can trigger live batches across many schemas, leaving completed groups' sessions open and exhausting that org's Duckgres max_connections, denying warehouse access to its other projects. The cache is also not bounded while the worker is idle, so its final set of connections persists indefinitely.
Prompt To Fix With AI
Redesign the session cache so retained idle sessions cannot exceed the DuckgresServer sink connection budget and are closed independently when their idle TTL expires (for example, use a bounded cache with scheduled eviction/cleanup). Ensure cache retention is accounted for by the per-org connection budget, rather than only active group leases, and close all sessions on worker shutdown.Severity: medium | Confidence: 92% | React with 👍 if useful or 👎 if not
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/duckgres/processor.py:145-151
**Idle Sessions Never Expire**
Expiration only runs during a later `acquire()` or `store()`. When groups drain after their final batches, their cached connections remain open past the 90-second idle limit; if enough groups drain together, these sessions can pin all Duckgres workers and prevent a new group from connecting, so cleanup needs an independent timer or group-release hook.
Reviews (1): Last reviewed commit: "perf(data-warehouse): reuse the duckgres..." | Re-trigger Greptile |
| self._entries[key] = (conn, created_at, time.monotonic()) | ||
| stale = [ | ||
| k | ||
| for k, (_, _, used) in self._entries.items() | ||
| if k != key and time.monotonic() - used > self.IDLE_TTL_SECONDS | ||
| ] | ||
| evicted = [self._entries.pop(k) for k in stale] |
There was a problem hiding this comment.
Expiration only runs during a later acquire() or store(). When groups drain after their final batches, their cached connections remain open past the 90-second idle limit; if enough groups drain together, these sessions can pin all Duckgres workers and prevent a new group from connecting, so cleanup needs an independent timer or group-release hook.
Prompt To Fix With AI
This is a comment left during a code review.
Path: products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/duckgres/processor.py
Line: 145-151
Comment:
**Idle Sessions Never Expire**
Expiration only runs during a later `acquire()` or `store()`. When groups drain after their final batches, their cached connections remain open past the 90-second idle limit; if enough groups drain together, these sessions can pin all Duckgres workers and prevent a new group from connecting, so cleanup needs an independent timer or group-release hook.
How can I resolve this? If you propose a fix, please make it concise.
PR overviewThis PR updates the Duckgres-backed data warehouse import pipeline to reuse a Duckgres session across a group's live batches, reducing session setup overhead during ingestion. The touched processor code manages cached sessions for warehouse source batch processing. One issue remains open after one earlier issue was addressed. The remaining concern is that cached idle sessions can accumulate outside the existing per-organization connection budget, allowing a user to churn through groups and temporarily occupy more worker sessions than intended. This could block subsequent ingestion for that organization until sessions expire or are evicted, so the PR still carries a limited resource-exhaustion risk. Open issues (1)
Fixed/addressed: 1 · PR risk: 5/10 |
|
Note 🤖 stamphog reviewed This touches the data-warehouse ingestion path with a security-relevant unresolved concern: hex-security-app and veria-ai both confirm, on the current head, that the session cache can still retain more connections than the org's connection budget allows, risking exhaustion of a customer's warehouse connections — the author's fixes addressed idle-expiry and org-transfer but not this capacity-bound issue.
Gate mechanics and policy version
Updated in place — this replaces 2 earlier stamphog review(s) on this PR. |
|
I reviewed the current changes and found no new security issues to report. |
|
The concern is valid. The docstring says "bounded…for up to IDLE_TTL after the group drains," but that bound is only enforced lazily on a subsequent The fix is straightforward — add a daemon sweeper thread and an And register a shutdown hook below the import atexit
atexit.register(_session_cache.clear)The daemon thread wakes every You'll also want a test for this: mock |
…y org Review fixes for the session cache: - Eviction no longer depends on later cache traffic: a daemon sweeper (lazy-started on first store, real-sleep bound so patched test clocks cannot hot-loop it) closes entries past the idle TTL or absolute age on a 30s cadence, and atexit clears the cache on graceful shutdown. Without this, a drained group's session pinned its duckgres worker indefinitely, outside the sink's org connection budget. - The cache key now includes the team's CURRENT org, resolved per batch: a team transferred between organizations gets a fresh connection to the new org's warehouse and the old entry ages out via the sweeper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed all three review threads in the latest commit:
All watched failing first; full processor + consumer suites green (48). 🤖 Generated with Claude Code |
|
I verified the sweeper and |
|
Both fixes look correct. Walking through each: Sweeper (P1): The daemon thread properly addresses the root issue. The Org key inclusion: Resolved per-batch One minor observation on the cold-path: a cache miss now does two Implementation looks solid. LGTM. Tip: You can customize Greptile's behavior for this repo with |
| key = (org_id, team_id, schema_id) | ||
| with self._lock: | ||
| previous = self._entries.get(key) | ||
| self._entries[key] = (conn, created_at, time.monotonic()) |
There was a problem hiding this comment.
Medium: Cached sessions bypass the connection budget
Each completed group adds an entry here for up to 120 seconds, but sink_max_concurrency only limits active group leases. A user who schedules batches across many schemas can churn through groups and retain more sessions than the organization budget permits, occupying the one-session workers and blocking subsequent ingestion. Enforce a per-organization cache limit no greater than its connection budget, evicting or closing an existing idle session before storing another.
…am once Review follow-ups: retained sessions pin duckgres workers, so bound them at MAX_SESSIONS_PER_ORG=4 per consumer pod with LRU eviction — a burst of sequentially drained groups can no longer accumulate an org's connections beyond its budget during the idle-TTL window. The per-batch Team lookup now feeds both the cache key and the connection config (one ORM read, not two on cache miss); _connect_to_duckgres takes the resolved org id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both follow-ups addressed:
Both watched failing first; processor + consumer suites green (50). 🤖 Generated with Claude Code |
|
Both fixes look solid. The implementation is clean and the tests are thorough.
One minor nit on
LGTM — both follow-ups addressed correctly. |
|
Thanks — I verified the LRU eviction now bounds each process-local cache to four entries and closes the evicted connection. However, this does not fully address my capacity finding: |
The backfill branch's 'with ... as conn' types conn non-Optional on first binding; the live path then assigned Connection | None to the same name (mypy assignment + unreachable errors — CI runs mypy, the pre-commit hook runs ty, which accepted it). Rename the live path's variable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🤖 CI report
|
Problem
After the co-claim fix (#72985), the duckgres sink's throughput ceiling is per-batch session cost. Production phase attribution (2026-07-22, consumer phase histogram + duckgres CP query-log correlation):
Every batch paid the cold costs because
process_batchopens a fresh connection each time — ~30-40s per batch, ~7-23/hr per group against a producer emitting ~74/hr.Change
A
(team_id, schema_id)-keyed session cache in the duckgres processor:Expected effect: warm batches at ~2s ⇒ several hundred batches/hour/group; combined with #72985 the sink comfortably outruns the producer, and one-snapshot-per-batch churn slows as backlogs stop accumulating.
How did you test this code?
I'm an agent; checks actually run:
test_processor.py+test_consumer.py+test_jobs_db.py: 98 passed.Rollback
Revert; behavior returns to fresh-connection-per-batch (slow but correct).
🤖 Agent context
Authored by Claude Code (Fable 5). Scope note: an earlier draft coalesced N batches into one DuckLake transaction, but the production trace showed the commit itself is cheap (<0.7s) while the costs are per-session — so this PR ships the smaller mechanism that captures the measured win without multi-batch status-consistency risk. Snapshot-growth hygiene (77k snapshots on the dogfood org) is a separate follow-up on the duckgres/compaction side.
🤖 Generated with Claude Code