Skip to content

feat: extend a pending timer through publish(timer_conflict="extend") - #254

Merged
lesnik512 merged 7 commits into
mainfrom
feat/timer-extend
Oct 26, 2026
Merged

lesnik512 merged 7 commits into
mainfrom
feat/timer-extend

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Adds timer_conflict to publish, OutboxPublisher.publish and OutboxResponse. With "extend", re-publishing a timer_id updates the pending timer instead of being ignored. This is meant for heartbeat timeouts, where every ping pushes one timer's deadline later.

Contract

  • The default stays "ignore", the existing ON CONFLICT DO NOTHING. Callers that replay a publish rely on a duplicate being a no-op, so extend is opt-in. A TimerConflict literal is exported. "extend" without a timer_id, or an unknown value, raises ValueError.
  • An extend is one INSERT ... ON CONFLICT DO UPDATE, with no SELECT before it and no commit of its own. The row id never changes.
  • Pending timer. next_attempt_at moves 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 reset attempts_count, first_attempt_at and last_attempt_at. deliveries_count is kept. The call returns the row id.
  • Running timer (leased). The row is left to its worker, and the call returns None. The extend locks the row and the claim uses SKIP 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_at is indexed, so these are not HOT updates. The timers page documents this, and #255 measures it.

Docs

  • The timers page covers extending a timer, what the returned id or None guarantees, what None costs a heartbeat, and the write cost.
  • The schema comment on the timer_id index notes it backs both conflict modes.

Tests

  • Contract suite, against both the fake and real Postgres:
    • a missing timer is inserted;
    • a pending timer is extended in place;
    • the deadline never moves earlier;
    • extending resets the retry history;
    • a leased timer is left to its worker and the call returns None;
    • the same timer_id on another queue is independent;
    • an explicit "ignore" behaves as before.
  • Real Postgres:
    • an uncommitted extend keeps a due row from being claimed;
    • an extend waits on an uncommitted claim and returns None;
    • ten concurrent extends in random order leave one row at the latest deadline;
    • rolling back leaves the timer as it was;
    • an extend is exactly one statement;
    • NOTIFY fires only when the timer is due;
    • end to end, the timer fires once with the latest payload;
    • a ping that misses a running expiry is followed by one that arms a new timer.
  • Fake broker: forwarding through the broker, the publisher and OutboxResponse; no re-dispatch; validation errors.
  • just test: 793 passed with the coverage gate on.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 1348 1.000 6.72 922 242 5000 5000 10000
consumer/w1/b100 1416 1.000 7.11 1154 243 5000 5000 10000
consumer/w2/b10 1616 1.000 6.72 923 242 5000 5000 10000
consumer/w2/b100 1840 1.000 7.11 1154 243 5000 5000 10000
consumer/w4/b10 1634 1.000 6.74 949 242 5000 5000 10000
consumer/w4/b100 1934 1.000 7.11 1154 243 5000 5000 10000
consumer/w1/b100/tfbs100 5673 0.010 6.11 1130 243 5000 5000 10000
consumer/w1/b100/interval1-10 1432 1.000 7.17 1196 265 5000 5000 10000
producer/w1/b100 2937 0.000 3.04 597 1 0 0 0
producer/w1/b100/ppt1 977 0.000 4.04 635 0 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls and other_calls within +2; wal_records within a 10% band; fetch_calls within 2x. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

@lesnik512 lesnik512 changed the title feat: extend a pending timer through publish(timer_conflict="extend") feat: extend timers through publish(timer_conflict="extend"), rearming running ones Oct 26, 2026
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.
@lesnik512 lesnik512 changed the title feat: extend timers through publish(timer_conflict="extend"), rearming running ones feat: extend a pending timer through publish(timer_conflict="extend") Oct 26, 2026
@lesnik512
lesnik512 merged commit 19c4825 into main Oct 26, 2026
15 checks passed
@lesnik512
lesnik512 deleted the feat/timer-extend branch October 26, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant