perf(msgqueue): optimistic publish + heal-on-23503 — remove the per-publish parent-row lock#7
Merged
Conversation
…ublish parent-row lock AddMessageEnsuringQueue now tries the plain MessageQueueItem insert first (whose FK check takes only a KEY SHARE lock on the parent MessageQueue row, so concurrent publishers do not serialize) and runs the ensure-queue CTE only when the insert fails with a foreign-key violation — i.e. only when the parent was actually reaped. Liveness for actively-published queues is already provided by the bind path (UpsertMessageQueue refreshes lastActive behind the 15s producer cache) and the subscriber 60s heartbeat, so the heal is expected at most once per queue per reap; a warn log makes a sustained heal rate visible. Replaces the unconditional ON CONFLICT DO UPDATE introduced in #2/#3, which took a row-exclusive tuple lock on the parent row for every publish to an auto-deleted queue (including every dispatcher-queue message) and convoyed all publishers on a handful of rows: 93% of the query's execution time was lock wait, 85% of total instance DB time (Cloud SQL Query Insights, hatchet-production). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hydeta
reviewed
Jul 8, 2026
| // never accrue the 1h of staleness the reaper requires (15s ≪ 1h). A | ||
| // sustained heal rate therefore means something else is deleting | ||
| // MessageQueue rows out-of-band. | ||
| m.l.Warn().Ctx(ctx).Str("queue", queue).Msg("parent queue row missing on publish; self-healing by recreating it") |
There was a problem hiding this comment.
Non-blocking, just an alerting note: this WARN also fires on legitimate idle-hour reaps — a queue that's genuinely quiet for an hour will WARN exactly once on its next publish, with nothing wrong. The comment above already frames it correctly (a sustained heal rate is the invariant-broken signal), so whoever wires alerting on this line should threshold on rate, not on any single occurrence.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Replaces the per-publish
ON CONFLICT DO UPDATEself-heal from #2/#3 with an optimistic publish + heal-on-23503: the hot path is a plainMessageQueueIteminsert (no parent-row lock), and the ensure-queue CTE runs only when the insert actually fails with the FK violation — i.e. only when the parent queue was genuinely reaped. Supersedes #5 (same shape, adds the heal warn-log canary and the safety-arithmetic comments) and #6 (whose throttled-touch cache is redundant — the analysis below shows why). This is also the shape we should update upstream hatchet#4330 to.Production impact of the current fix (why this PR exists)
Cloud SQL Query Insights,
hatchet-production, 1h window on 2026-07-08:WITH ensure_queue …CTE: exec timeBindQueueupsert lock wait (queued behind the same row locks)The mechanism:
ON CONFLICT ("name") DO UPDATE SET "lastActive" = NOW()takes a row-exclusive tuple lock on the parentMessageQueuerow, held until the transaction commits, on every publish to an auto-deleted queue. The dispatcher queue is durable + expirable, andbindAttrspromotes expirable ⇒ autoDeleted (internal/msgqueue/msgqueue.go), so every task-assignment message from 2 controllers + 4 engines + 4 schedulers serialized on a handful of rows — plus one dead tuple per message on those rows (WAL + vacuum pressure).sequenceDiagram participant P1 as publisher 1 participant P2 as publisher 2 participant P3 as publisher N… participant MQ as MessageQueue row<br/>(one per queue) participant MQI as MessageQueueItem Note over P1,MQI: BEFORE (deployed #2/#3): every publish upserts the parent row P1->>MQ: ON CONFLICT DO UPDATE (row-exclusive lock) P2--xMQ: blocked (tuple lock wait) P3--xMQ: blocked (tuple lock wait) P1->>MQI: INSERT item P1->>MQ: commit → release lock P2->>MQ: acquires lock… (convoy repeats per message)sequenceDiagram participant P1 as publisher 1 participant P2 as publisher 2 participant P3 as publisher N… participant MQ as MessageQueue row participant MQI as MessageQueueItem Note over P1,MQI: AFTER (this PR): plain insert, FK check takes KEY SHARE only P1->>MQI: INSERT item (FK → KEY SHARE on parent) P2->>MQI: INSERT item (concurrent — KEY SHARE ∥ KEY SHARE) P3->>MQI: INSERT item (concurrent) Note over MQ: parent row untouched on the hot path —<br/>no tuple locks between publishers, no dead tuplesFOR KEY SHARElocks (what an FK check takes on the referenced row) do not conflict with each other, so publishers never wait on publishers. They do conflict withDELETE, so an in-flight insert and the reaper serialize correctly against each other — that conflict is exactly what makes the FK violation a reliable, catchable signal that the parent is gone.The original bug, for context (why a self-heal exists at all)
sequenceDiagram participant Prod as producer (engine) participant Cache as bind cache (15s TTL) participant GC as CleanupMessageQueue (60s) participant DB as Postgres Note over Prod,DB: pre-#2: unpatched v0.84.0 Prod->>Cache: queue bound? → yes (cached) GC->>DB: DELETE MessageQueue WHERE autoDeleted AND lastActive < NOW()-1h Note over DB: parent row gone Prod->>DB: INSERT MessageQueueItem (>8KB pg_notify fallback) DB--xProd: 23503 MessageQueueItem_queueId_fkey Note over Prod: TriggerWorkflowRun → gRPC Internal / HTTP 500<br/>run silently droppedTwo upstream defects combined: only a subscriber's 60s heartbeat refreshed
lastActive(producing didn't count as activity), and the producer's 15s existence cache skipped the re-bind that would have recreated the queue. #2 fixed the liveness half (bind now refresheslastActive) and closed the FK half with the CTE — correct, but the CTE ran on every publish instead of only on the race.Why this PR is complete — the liveness argument
The reaper (
CleanupMessageQueue, 60s cadence) deletes queues whereautoDeleted AND lastActive < NOW() - INTERVAL '1 hour'(pkg/repository/sqlcv1/mq.sql). For that to bite, a queue must go a full hour with zerolastActiverefreshes. Three independent layers make that impossible for any queue anyone cares about:flowchart TD subgraph liveness [What refreshes lastActive] A["Producer bind refresh<br/>addMessage → upsertQueue<br/>RegisterTenant → upsertQueue (tenant exchange)<br/>15s TTL cache → UpsertMessageQueue SET lastActive=NOW()"] B["Subscriber heartbeat<br/>60s ticker → UpdateQueueLastActive<br/>(msgqueue.go:186-197)"] C["Heal-on-23503 (this PR)<br/>recreates queue with lastActive=NOW()<br/>+ WARN log"] end A -->|"≤15s staleness while publishing"| Q[MessageQueue row] B -->|"≤60s staleness while consuming"| Q C -->|"backstop; fires ≈ never"| Q Q --> R{"reap-eligible?<br/>lastActive < NOW()-1h"} R -->|"active queue: NO — 15s ≪ 1h"| S[never reaped mid-traffic] R -->|"truly idle >1h: YES"| T["reaped — but holds only expired items<br/>(items carry expiresAt = NOW()+5min;<br/>ReadMessages filters expiresAt > NOW())"]Walk every queue class through it:
task_processing_queue_v2, OLAP, DLQs)<id>_dispatcher_v1)<partition>_<controller>_v1)<tenant>_v1)RegisterTenanton every tenant-scoped publish, any payload size)The timeline that makes the last row airtight:
flowchart LR T0["publish at T<br/>(bind refreshed ≤15s ago →<br/>lastActive ≥ T-15s)"] --> T1["…queue goes quiet…"] T1 --> T2["T+1h: reap-eligible<br/>(requires NO bind for a full hour)"] T2 --> T3["reaper deletes queue<br/>FK ON DELETE SET NULL orphans<br/>only items that expired ≥55min ago"] T3 --> T4["next publish: bind cache expired<br/>long ago (15s ≪ 1h) → upsertQueue<br/>recreates queue BEFORE the insert"] T4 --> T5["insert succeeds — no 23503,<br/>no loss, no heal needed"]The heal path is therefore a backstop for windows that essentially can't occur in this codebase (a process pausing >1h between its cache check and its insert; something deleting
MessageQueuerows out-of-band) — which is exactly why it logs atWARN: a sustained heal rate is a signal that an invariant broke, not normal operation.Why we don't need to do more — alternatives considered and rejected
ON CONFLICT DO NOTHINGin the CTE — doesn't lock the existing parent row, so the reaper can still delete it between the CTE's conflict check and the item insert's FK check (the exact race, narrower). Worse, on conflict it returns no row, soINSERT … SELECT "name" FROM ensure_queuesilently inserts nothing in the common queue-already-exists case. Broken twice.DO UPDATE … WHERE lastActive < NOW() - '5 minutes'— Postgres takes the row lock before evaluating the auxiliaryWHERE, so this serializes publishers exactly like the unconditional version. No help.lastActivetouch) — correct, but strictly redundant: the 15s bind refresh already keeps every actively-published queue alive on all paths and at all payload sizes, while perf(msgqueue): throttled lastActive touch — publishers stop serializing on the parent row, active queues never reaped #6's touch only fires on publishes that reachAddMessageEnsuringQueue(durable autoDeleted + >8KB fallbacks) — as a liveness mechanism it's both later (5min vs 15s) and narrower than what already exists. It also adds a second cache and more parent-row writes than this PR. Its one real payoff — insurance if a future upstream sync regresses the bind refresh — is already covered by the heal + WARN log here: the regression would surface as heal logs, not as silent loss (and the orphanable items in that scenario are 5-min-TTL messages addressed to a dead consumer).AND NOT EXISTS (… "expiresAt" > NOW())) — sound hardening, but redundant given the timeline above (a reap-eligible queue provably holds only expired items). Can be added upstream as a follow-up if reviewers want belt-and-suspenders in SQL rather than in comments.addTenantExchangeMessagefans a copy of every tenant-scoped message to the tenant exchange regardless of enqueue transport; both the admin trigger and the event ingestor wrap the same call). The only client lever is keeping payloads under pg_notify's 8KB so the durable fallback never engages — worth pursuing separately for load reasons, but not a correctness requirement.What changed
pkg/repository/mq.go:AddMessageEnsuringQueuetries the plain insert first and runs the ensure-queue CTE only on an actual FK violation (with aWARNlog);isForeignKeyViolationhelper; comment updates. TheNotify>8KB fallback and the durableaddMessagepath route through it unchanged — they just inherit the optimistic behavior.pkg/repository/mq_selfheal_test.go: three new tests — the hot path must not rewrite the parent row (asserted via a backdatedlastActivesurviving a publish); a publish against a reaped queue must self-heal with bind attributes andlastActiverestored; a missing non-auto-deleted parent must still surface the FK error (real bugs stay loud).No SQL changes:
AddMessageEnsuringQueue(the CTE) stays as the heal statement;UpsertMessageQueue'slastActive = NOW()from #2 stays as the liveness mechanism.Testing
go test ./pkg/repository/ -run 'TestAddMessageEnsuringQueue|TestBindRefreshesLastActive'— all 7 pass against a real Postgres 15.6 (testcontainers), including the 4 pre-existing self-heal tests from fix(msgqueue): self-heal auto-deleted queues on enqueue to prevent FK violation (alt to #1) #2/fix(msgqueue): self-heal exclusive auto-deleted queues too (closes FK gap in #2) #3.go build/go vet/gofmtclean.Rollout
Cut
save-v0.84.0-baselayer.2-<sha>and watch two things: Query Insights lock-wait share (should collapse from 85% to noise — the 12.2k s/hr ofBindQueuewaits were convoy victims of the same row locks and should evaporate too), and the new healWARNlog (expected ≈ zero; any sustained rate means out-of-bandMessageQueuedeletes).Refs: #1 (closed — bind-refresh-only), #2/#3 (deployed — introduced the per-publish lock), #5 (closed — same shape as this, pre-canary), #6 (superseded — throttled touch), upstream hatchet#4330 (to be updated to this shape).
🤖 Generated with Claude Code