fix(event_bus): defer to a live claim holder instead of re-running it - #472
Conversation
## Summary Two halves of one hole (issue #470, follow-up to #469). `Process::Consumer` never heartbeat an event message's visibility timeout — `VisibilityHeartbeat` was wired into `ActiveJob::Executor` only. A handler slower than `visibility_timeout` was therefore redelivered while still running, and after `max_retries` dead-lettered without ever raising. The consumer now tracks each message for exactly as long as its handlers run, releasing before the archive. `Handler#claim_idempotency?` read a pending claim (`completed_at IS NULL`) as proof the holder had been killed mid-handler, and re-ran. That state equally describes a handler still running elsewhere, so a second delivery executed the handler concurrently with the live one — the double-execution `idempotent!` exists to prevent. `EventBus::ClaimBeat` now refreshes every in-flight claim's `processed_at` from the same beat that re-arms the message's VT, so claim liveness and message visibility go quiet together. A claim silent past twice the heartbeat interval is abandoned and re-runs as before; a fresher one is owned and the delivery skips. Skips are no longer silent: `pgbus.event_skipped` carries the reason (`:completed` / `:cached` / `:owned`), the claim's age and the `read_ct`. No migration; no behavior change on a table without `completed_at`. ## Test Coverage - handler_spec: live pending claim skips, does not stamp the holder's claim, does not cache, and instruments with age + read_ct; abandoned claim still re-runs; purged row claims; claim registered for the duration of handle and released when handle raises - claim_beat_spec: touches only pending claims, per-claim containment, legacy no-op - consumer_spec: message tracked while handlers run, released before archive, on_beat drives the claim refresh, unrouted message not tracked, ticker stopped on shutdown - visibility_heartbeat_spec: on_beat runs per extension, its failure contained - event_bus_flow_spec (real DB): live claim skipped / abandoned claim re-run / beat moves processed_at / completed claim untouched ## Verification - [x] bundle exec rubocop (617 files, clean) + docs rake lint - [x] bundle exec rspec unit suite: 4593 examples, 0 failures - [x] integration suite: 236 examples, 0 failures Claude-Session: https://claude.ai/code/session_015yNc4hDgowAEZVTmWKMANs
|
Running ultrareview automatically — Running ultrareview automatically — this reworks idempotency-claim liveness and visibility-heartbeat timing in concurrent event delivery, where a subtle race could cause duplicate handler execution, lost events, or claim corruption across threads/processes.. I'll post findings when complete. |
|
cubic can't run this ultrareview because your workspace has reached its monthly review limit. cubic has reviewed 121,696 of the 120,000 allowed lines of code this month. Reviews resume on 10 October 2026 (in 23 days). You've reached your flex budget. Increase your flex budget to resume reviews now, or learn how flex capacity spend limits work. To help optimise your usage, you can tune cubic to get the most out of your usage limits:
|
Summary
Two halves of one hole, follow-up to #469.
The consumer never heartbeat an event message's visibility timeout.
VisibilityHeartbeatwas wired intoActiveJob::Executor(lib/pgbus/active_job/executor.rb:99) and nowhere else, soProcess::Consumer#handle_messagelet an event's VT lapse under a running handler. A handler slower thanvisibility_timeout(30s by default) was redelivered while still running,read_ctclimbed on every redelivery, and aftermax_retriesthe event was dead-lettered without the handler ever raising.Consumer#dispatchnow tracks the message for exactly as long as its handlers run, releasing the entry before the archive so a beat can never re-arm a message that is already gone.A pending idempotency claim was read as proof of a crash.
Handler#claim_idempotency?treated apgbus_processed_eventsrow withcompleted_at IS NULLas "the prior holder was SIGKILLed mid-handler" and re-ran. That state equally describes a handler that is simply still running — another thread, another fork, another host — so a second delivery ran the handler concurrently with the live one: exactly the double-executionidempotent!exists to prevent.The claim now carries liveness rather than only a claim instant.
EventBus::ClaimBeatrefreshes every in-flight claim'sprocessed_atfrom the same beat that re-arms the message's VT, so claim liveness and message visibility go quiet together when a process dies::claimed— run:claimed— run:claimed— re-run (crash safety of #385, unchanged):owned— skip, defer to the holdercompleted_atset, or in the dedup cache:completed/:cached— skipDeferring is safe in both directions: the holder either completes (nothing is lost) or fails, leaving its own message for VT redelivery to recover.
A skip is no longer silent.
pgbus.event_skippedcarries the reason, the claim's age in seconds and the delivery'sread_ct;Metrics::Subscribercounts it aspgbus_event_countwithstatus: "skipped"and the reason as a tag.No migration, no new column. A table that has not run
pgbus:add_processed_event_completionis unaffected — a single-phase claim has no pending state and therefore no ownership question.Key changes:
lib/pgbus/event_bus/claim_beat.rb(new) — per-message claim livenesslib/pgbus/event_bus/handler.rb—claim_idempotency?→claim_idempotencyreturning aClaimResult; ownership window;process(message, claim_beat:)lib/pgbus/process/consumer.rb—dispatchwraps handlers inVisibilityHeartbeat.track;shutdownstops the tickerlib/pgbus/visibility_heartbeat.rb—on_beat:hook, contained liketouch_semaphoreCloses #470
Test plan
handle, without stamping the holder's claim, and without entering the dedup cachepgbus.event_skippedfires withreason: :owned, the claim age and theread_cton_beatmoves a real pending row'sprocessed_atand leaves a completed row alonehandle, and released whenhandleraisescompleted_at) behaves exactly as beforebundle exec rubocop(617 files) +docsrake lintcleanDeviations & judgment calls
The issue's first direction cannot work as written. "Treat a pending claim younger than
visibility_timeoutas owned" assumes the claim's age is informative, but with a claim-time-onlyprocessed_ata second consumer only ever sees the envelope after the VT has lapsed — so the age is>= VTby construction and the check would never fire. Only the issue's second direction (heartbeat the claim) makes the age meaningful, so that is what shipped.The root cause was one layer below the claim logic. The issue frames this as a claim-interpretation bug; the reason a second consumer sees a live envelope at all is that the consumer never heartbeat the message VT. Fixed there too — that removes the whole scenario for healthy processes, and the claim-ownership check covers what a heartbeat cannot serialize (a duplicate envelope, whose two messages have independent VTs).
"Let the message go back to VT redelivery" is not what a skip does.
handle_messagearchives unconditionally after the handlers loop, so a skip archives. I considered a:deferredstatus that suppresses the archive and rejected it: ownership is only ever established by a live beat, and a live holder's own message is still in the queue, so its failure is recovered there — while a defer would walkread_cttoward the DLQ on every duplicate.Ownership window is derived, not configurable —
effective_visibility_heartbeat_interval * 2, matching theConcurrencyprecedent (lib/pgbus/concurrency.rb:100). A config knob would need a docs config-drift entry for a value nobody should tune independently of the beat that drives it.Reused
processed_atas the liveness stamp instead of adding aheartbeat_atcolumn: no migration, no generator, no schema probe. The cost is thatprocessed_aton a pending row now means "last known alive" rather than "claimed at", andidempotency_ttlpurge is delayed by the handler's runtime — seconds against a 7-day window.New
pgbus.event_skippednotification rather than the issue's "add the age to thepgbus.event_processedpayload": a skip does not runhandle, soevent_processedis never emitted for one.Handler#processgained an optionalclaim_beat:kwarg — backwards compatible; a caller without it still works, its claim simply ages from the claim instant.Clock skew across hosts can mis-age a claim. Not addressed:
processed_athas always been stamped app-side withTime.now.utc, so this matches the rest of the table's semantics. Skew errs toward:owned(skip), which is the safe direction.VisibilityHeartbeat::Entry's ninth member takes the struct out of its 80-byte slot (measured: 80 → 160). Documented in place rather than worked around: entries exist only per in-flight message, so the table is bounded by the execution pool's capacity — a handful per process.Discovered along the way
spec/integration_helper.rbnever gotcompleted_at. Its comment claims the DDL mirrorslib/generators/pgbus/templates/migration.rb.erb, but the column added by the two-phase claim (#385) was never added there — so every integration run since #385 silently exercised the legacy single-phase fallback, and the two-phase claim had no real-database coverage at all. Added, with a conditionalALTERso an already-bootstrapped dev/CI database picks it up and areset_column_information/reset_completion_column_check!so the memoized probe sees it. This is what made the new integration specs fail first withunknown attribute 'completed_at'.A bare full
bundle exec rspecsegfaults inpuma/reactor.rbwhile Capybara boots a server. Reproduced identically on unmodifiedmain(exit 139) — environmental (puma 8.0.2 / ruby 3.4.2 / darwin 27), unrelated to this change. Unit and integration suites were run separately and are both green.https://claude.ai/code/session_015yNc4hDgowAEZVTmWKMANs
Summary by cubic
Fixes the event-bus double-execution hole in issue #470: a pending idempotency claim was read as proof of a crash and re-run while the original handler was still live, and the consumer never extended an event message's visibility timeout, so slow handlers could be redelivered mid-run.
Bug Fixes
ClaimBeatrefreshes each in-flight claim'sprocessed_aton the same beat that re-arms the message's visibility timeout, so a live holder keeps looking alive.pgbus.event_skippedwith the reason, claim age, andread_ct, and the metrics subscriber counts them aspgbus_event_countwithstatus: "skipped".completed_atkeep the single-phase claim behavior unchanged.Written for commit 4f40ca6. Summary will update on new commits.