Skip to content

feat(data-warehouse): per-org concurrency budget for the duckgres sink - #68808

Merged
EDsCODE merged 4 commits into
masterfrom
feat/duckgres-sink-org-concurrency-budget
Jul 9, 2026
Merged

feat(data-warehouse): per-org concurrency budget for the duckgres sink#68808
EDsCODE merged 4 commits into
masterfrom
feat/duckgres-sink-org-concurrency-budget

Conversation

@EDsCODE

@EDsCODE EDsCODE commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

The duckgres sink's only concurrency bound is pods x max_concurrency (16), and every in-flight group holds a connection to its org's duckgres server. KEDA already autoscales the deployment (3 to 8 pods on P90 slot utilization), so the failure mode of raising maxPods further is concrete: one org with a deep backlog saturates every pod's slots, utilization pegs, KEDA scales up, and now every new slot also hammers that same org's duckgres server. Scale-up makes the hot org hotter, and there is no fleet-wide limit on how many connections one org can receive.

Changes

Per-org budget, enforced fleet-wide at claim time.

  • DuckgresServer.sink_max_concurrency (new column, default 4, migration 1248): the max number of the org's groups the sink may process concurrently across all pods. One in-flight group holds at most one duckgres connection (opened per batch inside the group's serial loop), so this bounds the org's sink connection footprint independently of pod count. Admin-tunable per org from day one.
  • Enablement (duckgres_sink_enablement) now returns (team_id, org_id, budget) rows alongside the enabled-team set, on the same 60s refresh with the same keep-previous-on-error semantics. The queue DB has no teams table, so the claim query receives the mapping as unnest'd arrays.
  • The claim query counts live group leases per org (any owner, i.e. the fleet-wide in-flight picture) and ranks eligible groups oldest-first per org, admitting only the org's remaining slots. Crucially, a saturated org's groups are skipped rather than deferred, so they never head-of-line block a pod from filling max_groups with other orgs' work. The pod stays work-conserving.
  • Soft cap by design: overlapping claims from concurrent polls can transiently overshoot by a group or two for one poll window (leases commit in autocommit, so the race window is query-execution overlap). The org's duckgres max_connections remains the hard backstop. Teams without a mapping row (dev mode, mid-refresh race) are uncapped, preserving existing behavior; passing no mapping at all changes nothing.
  • New duckgres_sink_orgs_at_budget gauge (count only, no org label) plus an orgs_at_budget field on the maintenance heartbeat log. Operator contract: backlog growing while pod utilization is low and this gauge is up means the fleet is budget-limited - raise the org's budget or its duckgres capacity, not replicas. Backlog growing with utilization high means raise maxPods.

This also makes the existing KEDA signal truthful: a capped org's skipped work no longer inflates slot utilization, so autoscaling only adds pods when more pods genuinely help. Raising the charts maxPods beyond 8 is intended as a follow-up once this lands.

How did you test this code?

Automated tests only (I'm an agent; no manual prod testing). Full duckgres pipeline_v3 suite passes locally against a freshly created test DB (initial run had 5 failures that turned out to be a stale shared test DB carrying another branch's migration; all pass with --create-db). New tests, each catching a regression nothing covered before:

  • TestOrgConcurrencyBudget in test_jobs_db.py: a single fetch cannot claim past an org's budget; another pod's live leases count as usage and expired leases return the slot; a saturated org's older groups are skipped while another org's younger group fills max_groups (the work-conservation contract); an unmapped team is uncapped (the mapping must cap, never filter eligibility); count_orgs_at_budget counts a saturated org and not an under-budget one.
  • test_enablement.py: per-org budgets ride along with each enabled team and reflect per-org sink_max_concurrency values (guards the plumbing - an empty mapping silently means uncapped).

Existing tests pass unchanged: omitting the mapping preserves current claim behavior, and the consumer tests' keyed call-arg assertions are unaffected by the new kwarg.

Automatic notifications

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

Docs update

Internal consumer/scheduling behavior; no user-facing docs affected.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Authored with Claude Code (Fable 5) at Eric's direction, implementing a design brainstormed in-session for scaling the duckgres consumer fleet to a dozen-plus pods with a 3-5 connection cap per org (default 4, tunable).
  • Skills invoked: /django-migrations (additive column on posthog_duckgresserver, not a hot table, no external writers) and /writing-tests (value gate for the new test classes).
  • Key decisions, per Eric's explicit choices during design: soft cap counted at claim time rather than an exact slot-lease semaphore (the cap is load-shaping, not correctness, and exactness would add a table or serialize claims per org); budget lives on DuckgresServer rather than a code constant so per-org tuning needs no second migration; scope limited to cap + claim fairness (poll jitter and planner dedup deferred until replica counts actually grow).
  • One test expectation was corrected during verification: the fixture maps two teams to the same org, so after lease expiry the budget correctly re-admits the two oldest groups of that org - the initially written assertion expected the wrong set, the code was right.

🤖 Generated with Claude Code

@EDsCODE EDsCODE self-assigned this Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Migration SQL Changes

Hey 👋, we've detected some migrations on this PR. Here's the SQL output for each migration, make sure they make sense:

posthog/migrations/1250_duckgresserver_sink_max_concurrency.py

BEGIN;
--
-- Add field sink_max_concurrency to duckgresserver
--
ALTER TABLE "posthog_duckgresserver" ADD COLUMN "sink_max_concurrency" integer DEFAULT 4 NOT NULL;
ALTER TABLE "posthog_duckgresserver" ALTER COLUMN "sink_max_concurrency" DROP DEFAULT;
COMMIT;

Last updated: 2026-07-09 22:24 UTC (b3e1c93)

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🔍 Migration Risk Analysis

We've analyzed your migrations for potential risks.

Summary: 1 Safe | 0 Needs Review | 0 Blocked

✅ Safe

Brief or no lock, backwards compatible

posthog.1250_duckgresserver_sink_max_concurrency
  └─ #1 ✅ AddField
     Adding NOT NULL field with constant default (safe in PG11+)
     model: duckgresserver, field: sink_max_concurrency

Last updated: 2026-07-09 22:24 UTC (b3e1c93)

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(data-warehouse): per-org concurrenc..." | Re-trigger Greptile

Comment on lines +462 to +484
row = await conn.execute(
f"""
SELECT count(*)
FROM (
SELECT m.org_id
FROM {DUCKGRES_LEASE_TABLE} l
JOIN unnest(
%(budget_team_ids)s::bigint[],
%(budget_org_ids)s::varchar[],
%(budget_values)s::int[]
) AS m(team_id, org_id, budget) ON m.team_id = l.team_id
WHERE l.expires_at > now()
GROUP BY m.org_id
HAVING count(*) >= max(m.budget)
) saturated
""",
{
"budget_team_ids": [team_id for team_id, _, _ in team_org_budgets],
"budget_org_ids": [org_id for _, org_id, _ in team_org_budgets],
"budget_values": [budget for _, _, budget in team_org_budgets],
},
)
result = await row.fetchone()

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 result of conn.execute() in psycopg3 is the cursor itself, not a row — naming it row then calling .fetchone() on it reads as confusing. Every other caller in this file uses cur via async with conn.cursor() as cur:. Aligning the name removes the mismatch.

Suggested change
row = await conn.execute(
f"""
SELECT count(*)
FROM (
SELECT m.org_id
FROM {DUCKGRES_LEASE_TABLE} l
JOIN unnest(
%(budget_team_ids)s::bigint[],
%(budget_org_ids)s::varchar[],
%(budget_values)s::int[]
) AS m(team_id, org_id, budget) ON m.team_id = l.team_id
WHERE l.expires_at > now()
GROUP BY m.org_id
HAVING count(*) >= max(m.budget)
) saturated
""",
{
"budget_team_ids": [team_id for team_id, _, _ in team_org_budgets],
"budget_org_ids": [org_id for _, org_id, _ in team_org_budgets],
"budget_values": [budget for _, _, budget in team_org_budgets],
},
)
result = await row.fetchone()
cur = await conn.execute(
f"""
SELECT count(*)
FROM (
SELECT m.org_id
FROM {DUCKGRES_LEASE_TABLE} l
JOIN unnest(
%(budget_team_ids)s::bigint[],
%(budget_org_ids)s::varchar[],
%(budget_values)s::int[]
) AS m(team_id, org_id, budget) ON m.team_id = l.team_id
WHERE l.expires_at > now()
GROUP BY m.org_id
HAVING count(*) >= max(m.budget)
) saturated
""",
{
"budget_team_ids": [team_id for team_id, _, _ in team_org_budgets],
"budget_org_ids": [org_id for _, org_id, _ in team_org_budgets],
"budget_values": [budget for _, _, budget in team_org_budgets],
},
)
result = await cur.fetchone()

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 +443 to +445
"budget_team_ids": [team_id for team_id, _, _ in team_org_budgets] if team_org_budgets else [],
"budget_org_ids": [org_id for _, org_id, _ in team_org_budgets] if team_org_budgets else [],
"budget_values": [budget for _, _, budget in team_org_budgets] if team_org_budgets else [],

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 Budget params unpacking duplicated across two methods

The three list comprehensions that unpack team_org_budgets into budget_team_ids / budget_org_ids / budget_values appear verbatim in both get_delta_succeeded_and_lock (with the if team_org_budgets else [] guard) and count_orgs_at_budget (without the guard, because the early-return handles it). A small private helper — e.g. _budget_params(team_org_budgets)dict — would honour OnceAndOnlyOnce and reduce the surface for future drift if the tuple layout ever changes.

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 +29 to +40
@dataclass(frozen=True)
class SinkEnablement:
"""The sink's per-refresh view of who it serves and how hard it may push.

``team_org_budgets`` carries one (team_id, org_id, sink_max_concurrency)
row per enabled team — the queue DB has no teams table, so the claim query
receives this mapping as unnest'd arrays to enforce the per-org group
budget fleet-wide.
"""

team_ids: list[int]
team_org_budgets: list[tuple[int, str, int]]

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 frozen=True with mutable list fields gives an incomplete immutability guarantee

frozen=True only blocks attribute reassignment — it does not prevent callers from mutating the list contents (e.g. enablement.team_ids.append(42)). In practice the lists are never mutated here, so this causes no current defect. If true immutability is the intent, the fields could use tuple[int, ...] and tuple[tuple[int, str, int], ...] respectively; otherwise removing frozen=True (and relying on the caller convention) is equally honest about the actual semantics.

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!

@EDsCODE

EDsCODE commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Audit note: a session-level adversarial audit of the whole duckgres consumer stack found one HIGH in this PR's original commit — the budget filter ran after the candidate LIMIT, so a fully saturated org with a deep backlog (e.g. a pending backfill) could fill the window and make a pod claim nothing while other orgs had work. Fixed in 945661d: fully saturated orgs are now excluded before the LIMIT (mirroring the other-owner lease exclusion and its comment, which names exactly this failure mode); partially available orgs keep the post-LIMIT per-org ranking. Regression test test_saturated_orgs_deep_backlog_cannot_fill_the_candidate_window covers it. Known accepted residual: a partially available org can still fill the window and cost breadth for one poll, but the pod always makes progress.

@EDsCODE
EDsCODE changed the base branch from master to feat/duckgres-sink-failing-backfill-tracking July 7, 2026 22:55
@EDsCODE
EDsCODE changed the base branch from feat/duckgres-sink-failing-backfill-tracking to master July 7, 2026 23:51
@trunk-io

trunk-io Bot commented Jul 8, 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:50
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Playwright — all passed

All tests passed.

View test results →

@EDsCODE
EDsCODE changed the base branch from master to feat/duckgres-sink-failing-backfill-tracking July 9, 2026 18:17
@EDsCODE
EDsCODE requested a review from a team as a code owner July 9, 2026 18:17
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit 69a1e71 · box box-c948ca603fad · ready in 479s (push → usable) · build log · rebuilds on every push, torn down on close

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Docs from this PR will be published at posthog.com

Project Deployment Preview Updated (UTC)
posthog.com 🤷 Unknown Preview Jul 9, 2026, 8:02 PM

Preview will be ready in ~10 minutes. Click Preview link above to access docs at /handbook/engineering/

Base automatically changed from feat/duckgres-sink-failing-backfill-tracking to master July 9, 2026 21:53
EDsCODE and others added 2 commits July 9, 2026 14:57
The sink's only concurrency bound was pods x max_concurrency, and every
in-flight group holds a connection to its org's duckgres server - so
scaling replicas (KEDA already scales 3..8 on slot utilization) would
concentrate an unbounded number of connections on whichever org has the
deepest backlog. That coupling is what blocks raising maxPods.

- DuckgresServer.sink_max_concurrency (default 4): per-org cap on
  concurrently leased sink groups, admin-tunable per org
- enablement now returns (team_id, org_id, budget) rows alongside the
  team set; the queue DB has no teams table, so the claim receives the
  mapping as unnest'd arrays
- the claim query counts live group leases per org (any owner - the
  fleet-wide usage picture) and ranks eligible groups per org, admitting
  only the org's remaining slots; saturated orgs' groups are skipped, not
  deferred, so they never head-of-line block a pod from filling
  max_groups with other orgs' work
- soft cap: overlapping polls can overshoot by a group for one window;
  the org duckgres max_connections stays the hard backstop
- new duckgres_sink_orgs_at_budget gauge + maintenance log field so a
  budget-limited fleet is distinguishable from a pod-limited one (the
  KEDA utilization signal stays truthful: capped orgs' skipped work no
  longer inflates slot usage)

Unmapped teams (dev, mid-refresh race) stay uncapped; existing behavior
is unchanged when no mapping is passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… LIMIT

A fully saturated org with a deep eligible backlog (e.g. a pending
backfill) filled the candidate window with rows the group filter then
dropped, so the pod claimed nothing while other orgs had work - the
same starvation mode the other-owner lease exclusion already guards
against, and the exact scenario the budget targets. Saturated orgs are
now excluded pre-LIMIT; partially available orgs keep post-LIMIT
per-org ranking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@EDsCODE
EDsCODE force-pushed the feat/duckgres-sink-org-concurrency-budget branch from e6df3d3 to d4d05ca Compare July 9, 2026 22:03
%(budget_values)s::int[]
) AS t(team_id, org_id, budget)
),
org_usage AS (

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.

Medium: Concurrent claims can exceed the org budget

org_usage is computed from a statement snapshot, but no organization-scoped row or advisory lock serializes the subsequent lease inserts. An authenticated tenant can maintain eligible work across many schemas, allowing concurrent pods with different candidate windows to observe the same remaining slots and claim disjoint groups, opening more Duckgres connections than sink_max_concurrency. Serialize claims per organization or atomically allocate slots through a locked per-org counter.

@veria-ai

veria-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request adds a per-organization concurrency budget for the Duckgres warehouse sink, updating the job-claiming logic to limit how much work each org can run at once. The touched code manages database-backed leasing of Duckgres import jobs across schemas and workers.

There is still one open issue in the claim path: concurrent workers can calculate available organization capacity from the same snapshot and then lease separate batches, allowing the intended per-org concurrency limit to be exceeded. An authenticated tenant with enough eligible work could use this to drive more Duckgres connections than configured, creating a resource-exhaustion risk. No issues have been fixed or addressed yet, so the PR still needs a serialization or atomic allocation mechanism before the budget is reliable.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

@EDsCODE
EDsCODE merged commit d0ad31a into master Jul 9, 2026
248 of 250 checks passed
@EDsCODE
EDsCODE deleted the feat/duckgres-sink-org-concurrency-budget branch July 9, 2026 23:34
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-10 00:06 UTC Run
prod-us ✅ Deployed 2026-07-10 00:19 UTC Run
prod-eu ✅ Deployed 2026-07-10 00:23 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