Repository navigation
feat: extend a pending timer through publish(timer_conflict="extend") - #254
Merged
Merged
Conversation
Benchmark gate✅ gate passed
Gated (fails the build): |
A heartbeat tolerates one missed expiry: the next ping after the handler finishes arms a new timer. The rearm cost a schema migration and a second path through every terminal write.
Covers the None result for an extend on a leased timer: what it costs a heartbeat and how an app can close the gap. The integration test shows the next ping after the handler finishes inserts a fresh timer.
This was referenced Oct 26, 2026
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.
Adds
timer_conflicttopublish,OutboxPublisher.publishandOutboxResponse. With"extend", re-publishing atimer_idupdates the pending timer instead of being ignored. This is meant for heartbeat timeouts, where every ping pushes one timer's deadline later.Contract
"ignore", the existingON CONFLICT DO NOTHING. Callers that replay a publish rely on a duplicate being a no-op, so extend is opt-in. ATimerConflictliteral is exported."extend"without atimer_id, or an unknown value, raisesValueError.INSERT ... ON CONFLICT DO UPDATE, with no SELECT before it and no commit of its own. The row id never changes.next_attempt_atmoves to the later of the stored and requested deadlines. Only when the requested one is later does the row take the new payload and headers and resetattempts_count,first_attempt_atandlast_attempt_at.deliveries_countis kept. The call returns the row id.None. The extend locks the row and the claim usesSKIP LOCKED, so the two always happen one after the other: either the claim sees the new deadline, or the extend sees the lease.Why a running timer is not rearmed
An earlier revision of this PR queued the extend as a rearm on a leased row and applied it when the run ended (7d05f82, reverted in 739932b). For a heartbeat, losing that one extend costs at most one expiry. The handler finishes, its row is deleted, and the next ping inserts a fresh timer. An integration test shows this. The case left open is a ping that lands during a running expiry and is the operator's last. The timers page describes it and gives two ways an app can close it: re-check last activity in the handler, or retry the publish after
None.Keeping the rearm would have cost a schema migration (three columns and a CHECK) and a second path through every terminal write: delete, batched delete, DLQ, retry and cancel.
Cost
Every extend writes a new row version, even when the deadline does not move.
next_attempt_atis indexed, so these are not HOT updates. The timers page documents this, and #255 measures it.Docs
Noneguarantees, whatNonecosts a heartbeat, and the write cost.timer_idindex notes it backs both conflict modes.Tests
None;timer_idon another queue is independent;"ignore"behaves as before.None;OutboxResponse; no re-dispatch; validation errors.just test: 793 passed with the coverage gate on.