perf(ingest): split webhook bodies into a day-partitioned table
Not for admin-merge. This changes the hottest write path in the product (44M+ inserts/week across three routes) and is coupled to the us-east-1 move — see Sequencing.
Why
ingest.webhook_events is 532 GB of a 580 GB database, 424 GB of it raw provider bodies in TOAST. Nulling payloads reclaims nothing: dead TOAST only returns to the OS on a rewrite. So retention-by-UPDATE (what #6745 does) caps growth but can never shrink the table. Partitioning makes retention a DROP, which returns space immediately.
The blocker, and why the split runs the other way
A unique index on a partitioned table must include the partition key. Adding received_at to (provider, event_id) silently disables dedup — a redelivery has a different timestamp, so it no longer conflicts. Verified on a scratch database:
CREATE UNIQUE INDEX ... ON (provider, event_id, received_at);
-- same event, delivered a day apart, ON CONFLICT DO NOTHING
-- rows_for_one_event: 2
So webhook_events stays unpartitioned and keeps its dedup index untouched. Only the bodies move to ingest.webhook_payloads, partitioned by day. Nothing reads a body back, so that table is write-only and isn't in the drizzle schema — it's created by a custom migration, since drizzle has no PARTITION BY syntax.
One round trip
recordWebhookDelivery writes both tables in a single statement, so the write path costs the same as before and the tables can't diverge.
Its final SELECT reads from the event CTE, not the payload insert. On a redelivery the body already exists, ON CONFLICT DO NOTHING returns zero rows, and returning from that insert would report a good delivery as failed. That was a real bug in my first version, caught in the scratch database.
Tested against a real copy of production
A Neon branch off production — 532 GB, 102.1M rows:
| result | |
|---|---|
0089 DROP COLUMN payload on 102.1M rows | 103ms — catalog-only, no rewrite |
0090 partitioned table + DEFAULT + maintenance fn | ~500ms |
| Maintenance: create 14 days ahead | 15 partitions, 172ms |
| Retention: drop a 50k-row partition | 35ms, space returned immediately |
DELETE of the same volume, for comparison | 226ms, 46 MB still on disk |
Then 500 real production payloads (GitHub + Linear) replayed through recordWebhookDelivery:
pass 1 (fresh): 500/500 recorded, 0 null
pass 2 (redeliver): 500/500 returned the SAME id, 0 null
events +500 (not +1000) payloads +500
round-trip fidelity: 200 identical, 0 mismatched
partition routing: 1000 rows in today's partition
webhook_payloads_default: 0 rows
Also confirmed jsonb still rejects NUL, so stripNullChars remains load-bearing — all three routes still call it.
Operational notes
- A missing partition is a hard insert failure (
no partition of relation found) — on this path that's 500s and eventual event loss once a provider stops retrying. Hence theDEFAULTpartition. It must stay empty; alert oncount(*) FROM ingest.webhook_payloads_default > 0. - Recovery when DEFAULT has rows is a drain-and-reattach in one transaction; verified working.
maintain_webhook_payload_partitions()needs a daily QStash schedule. Not created yet — it needs a job route, which I'd rather add in the same PR that wires the cutover.
Sequencing
The migration is sub-second even at 532 GB, so it's safe to apply anytime. But it drops the existing bodies — intentional, since retention discards them anyway, at the cost of losing the current debugging window at cutover.
The natural time to apply it is during the us-east-1 move: you restore the last 7 days straight into the partitioned table, so there's no gap and no separate backfill over a 500 GB table.
Testing
- Lint and typecheck pass
- Everything above ran against a real production copy, not a fixture
- The replay harness is in my scratchpad rather than the repo; happy to add it as a script if that's useful
https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
Summary by cubic
Writes webhook bodies to a day-partitioned ingest.webhook_payloads while keeping ingest.webhook_events for identity/state; retention becomes a DROP that frees space immediately with dedup on (provider, event_id) unchanged. This is expand-only: the legacy payload column remains for existing rows; new deliveries write bodies only to the partitioned table.
- recordWebhookDelivery writes event state and body in one statement and returns from the event CTE to preserve redelivery semantics; GitHub/Linear/Notion routes now call it.
- ingest.webhook_payloads is write-only, partitioned by day with a DEFAULT partition, created via custom SQL and excluded from
@superset/dbschema; webhook_events stays unpartitioned and retains the unique (provider, event_id).
Rollout and operations
- Apply migration 0089_webhook_payloads_partitioned (adds the partitioned table and maintenance function; no data drop).
- Schedule the new job route /api/ingest/jobs/maintain-partitions via QStash to create 14 days ahead and drop partitions older than 7 days. Required: add the schedule.
- Monitor count(*) FROM ingest.webhook_payloads_default; if > 0, drain and reattach those dates before creating their partitions.
- No behavior change for event identity, dedup, or retries; the hot path remains a single round trip.
Summary by CodeRabbit
-
New Features
- Improved webhook delivery tracking across GitHub, Linear, and Notion integrations.
- Added reliable handling for duplicate and failed webhook deliveries, including retry tracking.
- Added scalable webhook payload storage with automated partition maintenance and retention.
-
Bug Fixes
- Prevented duplicate webhook payloads from being stored during redeliveries.
- Improved consistency when recording webhook events and their processing status.