fix(msgqueue): prevent MessageQueueItem_queueId_fkey (23503) when an auto-deleted queue is reaped mid-publish#4330
Conversation
…eted queue is reaped mid-publish On the Postgres message-queue backend, triggering a workflow can intermittently fail with `MessageQueueItem_queueId_fkey` (SQLSTATE 23503), surfacing as an opaque gRPC Internal / 500. It's a race: CleanupMessageQueue reaps an auto-deleted queue (idle >1h, refreshed only by a subscriber heartbeat) while a producer, whose 15s existence cache skips the re-bind, inserts against the now-missing parent via the >8000-byte pg_notify fallback. Two complementary fixes: - UpsertMessageQueue refreshes "lastActive" on conflict, so producing counts as activity and an actively-used queue is no longer reaped out from under itself. - New AddMessageEnsuringQueue upserts the parent in the same statement as the item insert, so a reap can't race between "ensure" and "insert". Routed for every autoDeleted queue (the reaper's exact predicate) — including exclusive ones (the dispatcher queue and controller consumer queues), threading the queue's real durable/autoDeleted/exclusive attributes so a recreated row keeps correct semantics. Adds testcontainers coverage for missing-parent create, reaped-queue recreate (non-exclusive and exclusive), and lastActive refresh on bind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@blakewatters is attempting to deploy a commit to the Hatchet Team on Vercel. A member of the Team first needs to authorize it. |
…nly on an actual FK violation The unconditional ensure-on-every-publish CTE (ON CONFLICT DO UPDATE SET lastActive = NOW()) takes a row-exclusive tuple lock on the parent MessageQueue row per publish, so all concurrent publishers to the same auto-deleted queue (including every dispatcher-queue message) serialize on a handful of rows. Measured in production: 93% of the statement's execution time was lock wait, 85% of total instance DB time. The plain MessageQueueItem insert's FK check takes only a KEY SHARE lock, which does not conflict with other KEY SHARE locks (publishers never wait on publishers) but does conflict with DELETE (a concurrent reap and an in-flight insert serialize correctly). So: insert optimistically, and run the ensure-queue CTE only when the insert fails with 23503 — i.e. only when the parent was actually reaped. With the bind path refreshing lastActive behind the producer's 15s existence cache (earlier commit in this PR), an actively-published queue can never accrue the 1h of staleness the reaper requires, so the heal fires at most once per queue per reap; it logs at WARN because a sustained rate indicates out-of-band MessageQueue deletes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Revision pushed (46d1190): the self-heal is now optimistic — the ensure-queue CTE no longer runs on every publish, only on an actual FK violation. The PR description has been updated to match. Same correctness guarantee, but the reason for the change deserves the detail below, because we learned it the hard way. What happenedWe deployed the previous form of this patch (ensure-queue CTE on every publish to an auto-deleted queue) to a production cluster. It fixed the 23503 as designed — and then caused a lock-contention incident. Cloud SQL Query Insights over a 1h window:
The mechanism: sequenceDiagram
participant P1 as publisher 1
participant P2 as publisher 2..N
participant MQ as MessageQueue row (one per queue)
participant MQI as MessageQueueItem
Note over P1,MQI: previous revision: ensure-queue CTE per publish
P1->>MQ: ON CONFLICT DO UPDATE (row-exclusive lock)
P2--xMQ: blocked (tuple lock wait)
P1->>MQI: INSERT item
P1->>MQ: commit → release
P2->>MQ: acquires… (convoy repeats per message)
sequenceDiagram
participant P1 as publisher 1
participant P2 as publisher 2..N
participant MQ as MessageQueue row
participant MQI as MessageQueueItem
Note over P1,MQI: this revision: optimistic insert, heal only on 23503
P1->>MQI: INSERT item (FK check → KEY SHARE on parent)
P2->>MQI: INSERT item (concurrent — KEY SHARE ∥ KEY SHARE)
Note over MQ: parent row untouched on the hot path
Why the optimistic form is still fully correctThe plain insert's FK check takes And with the first half of this PR in place (binding refreshes Alternatives we considered and rejected for the hot path: New regression tests
All msgqueue tests (the four originals plus these three) pass against Postgres 15.6 via testcontainers; 🤖 Generated with Claude Code |
Summary
On the Postgres message-queue backend (
SERVER_MSGQUEUE_KIND=postgres), triggering a workflow can intermittently fail with an opaque gRPCInternal/ HTTP 500. The root cause is a race between the queue-retention GC and a producer publishing a large message: the GC deletes an auto-deleted queue out from under an in-flight insert, and the insert fails its foreign key.This PR makes producing to a queue count as keeping it alive, and makes the fallback insert self-healing, so the FK violation can't occur regardless of GC timing.
The bug
It's rare (timing-dependent) and surfaces to callers as a non-obvious
Internal/500 onTriggerWorkflowRun, silently dropping the run.Root cause
Two facts combine into a race:
Only a subscriber keeps an auto-deleted queue alive.
CleanupMessageQueuedeletes any queue where"autoDeleted" = true AND "lastActive" < NOW() - INTERVAL '1 hour'(runs every 60s).lastActiveis refreshed only by an active subscriber's 60s heartbeat (UpdateMessageQueueActive). Binding/publishing did not refresh it — so a queue with active producers but no subscriber (e.g. a per-tenant fanout exchange nobody is streaming, or a dispatcher queue whose dispatcher is briefly down) goes stale and gets reaped.The producer skips the re-bind for 15s.
upsertQueuecaches "this queue exists" in a 15s in-memory TTL cache and returns early on a hit, so within that window it does not re-runBindQueue(which would recreate the row).So: the GC deletes the queue; within the 15s cache window the producer skips the re-bind; and the next insert references the now-missing parent. The
>8000-bytepg_notifyfallback inNotifyroutes large payloads to a durableINSERT INTO "MessageQueueItem", which then violatesMessageQueueItem_queueId_fkey. (The FK isON DELETE SET NULL, so existing items are orphaned rather than blocking the delete — only the next insert races and fails.)Repro conditions
SERVER_MSGQUEUE_KIND=postgresautoDeletedqueue with no active subscriber for >1h (so it gets reaped).pg_notify's 8000-byte limit (so it takes the durable-insert fallback).The fix
Two complementary changes — the first removes the precondition, the second guarantees correctness even if the race still happens:
1. Binding a queue counts as activity.
UpsertMessageQueue(called byBindQueue) now alsoSET "lastActive" = NOW()on conflict. A queue that is actively produced to re-binds at least every 15s (when the existence cache expires), so it can no longer go stale and be reaped out from under its own traffic.2. The durable fallback self-heals — without touching the parent row on the hot path.
AddMessageEnsuringQueuefirst attempts the plainMessageQueueIteminsert. Its FK check takes only aFOR KEY SHARElock on the parent row: KEY SHARE locks do not conflict with each other, so concurrent publishers never serialize on the parent — but they do conflict withDELETE, so an in-flight insert and the reaper serialize correctly against each other, which makes the FK violation a reliable, catchable signal that the parent is gone. Only on an actual 23503 does it run the ensure-queue CTE, which recreates the parent and inserts the item in the same statement:Because the heal (re)creates the parent in the same statement as its insert, there is no window for the GC to race between "ensure" and "insert", and the retried insert can't fail. With change 1 in place, an actively-published queue can never accrue the 1h of staleness the reaper requires (the bind refresh runs at least every 15s), so the heal fires at most once per queue per reap — it logs at
WARNbecause a sustained heal rate indicates something is deletingMessageQueuerows out-of-band.Notify's>8000-byte fallback and the durableaddMessagepath route throughAddMessageEnsuringQueuewhenever the queue isautoDeleted— i.e. exactly the set the reaper can delete. This includes exclusive auto-deleted queues (the dispatcher queue is expirable ⇒ auto-deleted and exclusive; controller consumer queues are auto-deleted + exclusive), so the queue's realdurable/autoDeleted/exclusiveattributes are threaded through and a recreated row keeps correct semantics.ON CONFLICT DO UPDATEonly toucheslastActive, so an existing queue'sexclusiveConsumerIdis preserved; a recreated one has aNULLconsumer until the nextBindQueue, which is safe (ReadMessageskeys onqueueId). Durable, non-auto-deleted queues (e.g. the shared task queue) are never reaped and keep the plain insert.Tests
pkg/repository/mq_selfheal_test.go(testcontainers Postgres):AddMessageEnsuringQueuecreates a missing parent (no 23503) and mirrors the supplied bind attributes.CleanupMessageQueuereaps a stale queue,AddMessageEnsuringQueuerecreates it — covering both a non-exclusive queue and an exclusive auto-deleted queue (exclusive preserved).UpsertMessageQueuerefresheslastActive, so a re-bound queue survives the inactivity sweep.lastActiveuntouched (guards against reintroducing the per-publish upsert).lastActiverestored, message stored) instead of surfacing 23503.sqlcregenerated with the pinnedv1.29.0;go build ./...andgo vetclean.Type of change
🤖 AI Disclosure
I acknowledge that an LLM was used in the creation of this Pull Request, in accordance with Hatchet's AI_POLICY.md.
Details: Claude Code (Claude Opus 4.8; revision by Claude Fable 5). Correlated production logs with the engine source to isolate the reaper-vs-producer race, traced the queue taxonomy against the cleanup predicate to find the exclusive-queue gap, wrote the fix + regression tests, and regenerated sqlc. The optimistic-insert revision was driven by production lock-contention telemetry from running the earlier form of this patch. Reviewed and edited by a human before submission.