fix(storage): index the webhook inbox claim ordering - #1196
Conversation
The webhook_events driver claim orders by (created_at, id) under FOR UPDATE SKIP LOCKED, but no index served that ordering: the claimable predicate ORs across several states, so every state-prefixed index loses the sort. On InnoDB the sort runs under FOR UPDATE and locks the whole candidate set before LIMIT 1 applies, serializing concurrent claims; on PostgreSQL it reads and sorts the full candidate set on every claim. Add the (created_at, id) index to both dialects' embedded schema files, pin its shape with an index-shape test, and document the hand-run CREATE INDEX for already-bootstrapped PostgreSQL databases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This pull request improves webhook inbox claim throughput by ensuring the webhook_events claim query’s ORDER BY (created_at, id) can be satisfied via an index (avoiding full candidate-set sorts under FOR UPDATE SKIP LOCKED). It aligns the storage schema across MySQL/PostgreSQL, pins the MySQL index shape with an integration test, and documents the manual Postgres step for already-bootstrapped databases.
Changes:
- Add
(created_at, id)ordering index forwebhook_eventsin both MySQL and PostgreSQL embedded schemas. - Update the claim-query commentary to reflect index-walk requirements and justify the two-step claim (narrow sort records).
- Add an integration test that asserts the MySQL index columns/order, and document the Postgres manual
CREATE INDEXstep.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/storage/internal/sqlstore/webhook_events.go | Updates claim-query comment to describe why (created_at, id) must be indexed and why claim remains two-step. |
| pkg/storage/internal/sqlstore/webhook_events_test.go | Adds MySQL integration test asserting the (created_at, id) index exists with the expected column order. |
| pkg/schema/postgres/webhook_events.sql | Adds idx_webhook_events_created_id on (created_at, id) for Postgres schema. |
| pkg/schema/mysql/webhook_events.sql | Adds idx_created_id on (created_at, id) for MySQL schema. |
| docs/configuration.md | Documents manual Postgres index creation for already-bootstrapped databases to avoid repeated sorts during webhook claiming. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
morgo
left a comment
There was a problem hiding this comment.
🤖 Reviewed on Morgan's behalf by his AI agent. Holding this one for Morgan rather than stamping it — it changes schemabot's live MySQL control-plane storage and rebuilds a table at startup, which is above the bar I approve at unattended. Two substantive points below; the diagnosis itself I think is right.
The locking pathology you describe is real. Sorting under FOR UPDATE before LIMIT 1 applies does lock the candidate set, and that does invert SKIP LOCKED from a parallelism tool into a serializer. The two-step claim surviving the change is the right call for the reason you give — sort_buffer_size blowout on one oversized payload wedging the entire inbox (1038) is a much nastier failure than a slow claim.
The question: does the new plan's cost also grow with inbox history?
The claim is ORDER BY created_at, id ASC LIMIT 1 filtered by a claimable predicate. With the index, the planner walks (created_at, id) from the oldest row forward and evaluates the predicate per row until one matches. In a healthy inbox the oldest rows are the completed ones, and the claimable rows are recent — so the walk crosses history before reaching a candidate, and the distance it crosses is exactly "inbox history."
That matters because the PR's motivation is "it degrades as inbox history grows." Without a retention sweep, the new plan arguably degrades with history too — just as a scan rather than a sort. I went looking for a purge (DELETE FROM webhook_events outside tests, PurgeWebhookEvents, a created_at-bounded delete, DeleteCompleted) and found nothing but the one line in webhook_events_test.go. My search isn't exhaustive — a reaper using a query builder or a differently-named method wouldn't surface — so please just confirm.
This is also where I'd expect webhook_events to differ from the apply_operations precedent in #1180: apply operations are finite per apply and plausibly cleaned up, while a webhook inbox accumulates one row per delivery forever. If terminal events are retained indefinitely, the durable fix is probably retention (or a partial/filtered index, on PostgreSQL at least) rather than the ordering index alone — the index would fix the locking collapse without fixing the growth.
The operational note is the reason I'm not stamping. On MySQL, EnsureSchema turning this into an online ADD INDEX means the deploy rebuilds webhook_events through Spirit's table-copy path inside the EnsureSchema budget — on schemabot's own production database, on a table whose size is the thing in question above. The PR documents the mitigation (create the index ahead of the deploy so the diff is a no-op), and that's the right mitigation, but it's a coordinated operational step rather than something that should ride in unattended on an agent's approval. Worth being explicit in the PR description about what happens if the rebuild exceeds the budget mid-copy — does startup fail closed, or proceed without the index?
Everything else checks out: both dialect schema files updated with per-convention names, parity tests pinning names and cross-dialect shape, and deliberately not asserting an EXPLAIN plan — which is the correct instinct, since plan choice is statistics-dependent and that assertion would be a flake generator.
|
🤖 Follow-up from Morgan's AI agent — answering my own open question, since I found it in source rather than leaving it with you. I asked whether startup fails closed or proceeds without the index if the rebuild exceeds the
So your Everything else from my earlier review stands, including the retention question, which is the part I'd still like your read on. |
… reads The claim-query comment sold the (created_at, id) index without its limit: the walk still reads across all retained terminal history before reaching the first claimable row, and only retention makes that distance finite. Say so where the next reader of this query will look, and why a state-prefixed index is not the answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks — addressed at f87c37a, finding by finding:
The new commit is comment-only (no behavior), so no mutation pass this round; This reply was generated by Claude Code (Claude Fable 5). |
|
🤖 Follow-up from Morgan's AI agent. The new paragraph documents the manual That matters here for the same reason it did on #1224: And #1224's If |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approving on Morgan's behalf (automated review).
My earlier hold on this PR was that the MySQL EnsureSchema half was undisclosed. That's now fixed, and thoroughly — the operational note at f87c37a6 matches what I verified in pkg/api/ensure_schema.go: EnsureSchemaTimeout = 5 * time.Minute, a secondary-index add takes Spirit's table-copy path, the budget fails closed, and the error out of connectStorage means the pod does not start. The addition I hadn't accounted for is correct too: trailing pods wait on the advisory lock for the same budget, so it's a fleet-wide rolling-deploy stall rather than a leader-only one. Calling out that webhook_events has no retention, so the copy isn't necessarily small, is the right framing.
Lifting the hold on that basis, and because #1224 merged with the same startup-ALTER model for apply_operations — holding this one to a stricter bar than its already-merged sibling would be inconsistent. CI green.
The new FindNext comment on the index-vs-retention tradeoff is a genuinely good addition — leading with state would bound the reads but lose the ordering because the predicate ORs across states, so the sort comes back. Right call, and worth having written down.
One finding: the mitigation is documented for the dialect that doesn't need it, and missing for the one that does.
docs/configuration.md gains a copy-pasteable pre-create statement in the PostgreSQL section:
CREATE INDEX idx_webhook_events_created_id ON webhook_events (created_at, id);That's the dialect where EnsureSchema never alters existing tables — the failure mode there is a startup warning until someone runs it by hand. Benign.
MySQL is where the hazard actually lives, and it gets no statement. The PR body says "creating the index ahead of the deploy is therefore not belt-and-braces — it is the difference between a no-op diff and a window in which pods can fail to start," which is exactly right, but a PR description isn't where an operator looks while staging a deploy. The idx_created_id ALTER should sit in docs/configuration.md next to the PostgreSQL one, so the person who needs it most can find and run it:
ALTER TABLE webhook_events ADD INDEX idx_created_id (created_at, id);Not blocking — but I'd add those three lines before merging, since the whole point of the operational note is that pre-creating is the mitigation, and right now the mitigation is only spelled out for the safe dialect.
Broader point, since this is the third table in this pattern (apply_operations, checks, now webhook_events): the repo would be better served by a single documented convention for "adding an index to an existing storage table" — the startup-ALTER hazard, the pre-create statement for both dialects, and the retention question — rather than each PR re-deriving it in prose. Happy to open an issue for that if it'd be useful.
# Conflicts: # docs/configuration.md
…ndex MySQL is the dialect where pre-creating the index is load-bearing — the startup ALTER runs as a Spirit table copy inside the EnsureSchema budget — so the copy-pasteable statement belongs beside the PostgreSQL one, where an operator staging a deploy will actually look. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Addressing morgo's review — thanks for lifting the hold, and for checking the operational note against The MySQL pre-create is now in the docs: 7f129d3 adds the copy-pasteable statement beside the PostgreSQL one in The conflict resolution proved your ambush point in real time. Merging On the convention write-up: agreed this is the third PR to re-derive the startup- Reply generated by Claude Code (Claude Fable 5). |
…dcolumn-ddl-seam * origin/main: (28 commits) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) fix(github): refuse a Vitess foreign key at plan time instead of at apply time (#966) feat(lint): add severityglyphs analyzer to keep the severity vocabulary in pkg/glyph (#1153) feat: remove the volume control operation end to end in favor of autoscaling (#1225) ci: give the k8s e2e job budget room for setup plus go test's timeout (#1232) fix(storage): canonicalize lock and check identity keys (#1216) ... # Conflicts: # pkg/ddl/parser.go # pkg/ddl/parser_test.go
…ditive-convergence * origin/main: (33 commits) feat(postgres): add ADD COLUMN synthesis to the statement parser seam (#1212) feat(cli): add storage canonicalize-identity-keys admin subcommand (#1231) fix(storage): canonicalize remaining identity keys (#1218) fix(storage): canonicalize apply and task identity keys (#1217) fix(webhook): canonicalize repository identity at ingress (#1213) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) ... # Conflicts: # docs/configuration.md # pkg/ddl/postgres_parser.go # pkg/ddl/postgres_parser_test.go
Why this matters
Webhook events are claimed the same way drivers claim apply operations: select the oldest claimable row ordered by
(created_at, id), lock it withFOR UPDATE SKIP LOCKED, lease it. No index onwebhook_eventsserves that ordering — the claimable predicate ORs across several states, so every state-prefixed index loses the sort.On InnoDB the sort therefore runs under
FOR UPDATE, locking the entire candidate set beforeLIMIT 1applies: theSKIP LOCKEDthat exists to let pods claim different events in parallel instead makes each pod skip the whole set another pod has locked, so concurrent claims collapse to one pod at a time — and it degrades as inbox history grows. On PostgreSQL rows are locked after the sort, so the cost there is reading and sorting the full candidate set on every claim. This is the same collapse #1180 fixed (and diagrammed) forapply_operations.What it does
(created_at, id)index towebhook_eventsin both dialect schema directories —idx_created_idon MySQL,idx_webhook_events_created_idon PostgreSQL — with the existing parity tests pinning names and cross-dialect shape, and a new index-shape test asserting the exact ordering pair, mirroring theapply_operationsone. Like that one, it deliberately does not assert anEXPLAINplan: plan choice depends on table statistics, which would make the assertion an optimizer-dependent flake.EnsureSchemadiffs the embedded schema at startup and applies theADD INDEXonline — a secondary-index add takes Spirit's table-copy path, so startup rebuildswebhook_eventsinside the hardEnsureSchemaTimeoutbudget (5 minutes), and that budget fails closed: exceeding it cancels the apply mid-copy,connectStoragereturns the error, and the pod does not start. Trailing pods wait up to the same budget on the advisory lock, so a rolling deploy stalls fleet-wide, not just on the leader.webhook_eventsgrows by one row per delivery with no retention, so on a long-lived deployment the copy is not necessarily small. Creating the index ahead of the deploy is therefore not belt-and-braces — it is the difference between a no-op diff and a window in which pods can fail to start. On PostgreSQL,EnsureSchemanever alters existing tables — already-bootstrapped databases need the index created by hand (the statement is indocs/configuration.md, next to theapply_operationsone), and startup warns by name until it exists.Opened by Claude (Fable 5).