Skip to content

[bug] Delivery outcome writes are unguarded — a slow attempt can resurrect a delivered event, bury a dead one, and stall the attempt counter #239

Description

@serge-ivo

claimDelivery (added deliberately, with a good comment explaining why) stops two sweeps from
starting the same delivery. But the two writes that END an attempt are unguarded, so a slow
attempt that outlives its claim can still corrupt the row after another attempt has finished with
it.

The gap

The claim is a CAS that pushes next_attempt_at forward by CLAIM_HOLD_MS (5 min):

UPDATESET next_attempt_at = ?2 WHERE id = ?1 AND status = 'pending' AND next_attempt_at IS ?4

Both terminal writes then key on id alone — no status guard, no claim identity:

-- markDelivered (:161)
UPDATESET status='delivered', attempts = attempts + 1, … WHERE id = ?1

-- markAttemptFailed (:176)
UPDATESET status='pending', attempts = ?2, next_attempt_at = ?3, … WHERE id = ?1

attempts is also read from the row the sweep SELECTed (connections.ts:391 passes
row.attempts), so it is a stale absolute value, not an increment.

Three consequences

1. A delivered event is resurrected and delivered again.

  • t=0 sweep A claims D, starts the consumer (run_pipeline → Workflow). The attempt stalls.
  • t=5m the hold expires, D is due, sweep B claims and succeeds → markDelivered.
  • t=6m attempt A finally throws → markAttemptFailedstatus='pending', due in ~1 min.
  • t=7m sweep C delivers it a third time.

This is precisely what the module promises it prevents: "the consumer's irreversible work
(creating a site, spending tokens) can't happen twice."
site-builder builds and bills twice;
site-deploy deploys twice.

2. A dead-lettered delivery is silently marked delivered. Reverse order — the failing attempt
dead-letters D, then the slow successful attempt calls markDelivered on it. The row leaves
status='dead', so it vanishes from ?status=dead and from replay. A real failure disappears.

3. attempts can stall, so MAX_ATTEMPTS is never reached. Two overlapping attempts both write
attempts = <same stale value> + 1. A delivery that keeps overlapping never advances past N+1 and
therefore never dead-letters — it retries forever, which is the opposite of the bounded
at-least-once contract.

Suggested shape

The codebase already solves this exact class three times, all with a guarded terminal write:

  • setWorkCardProgress — guarded on status = 'running' so a late progress write can't resurrect
    a finished card.
  • releaseSessionDriver — scoped to driver_id so a late release can't free someone else's claim.
  • AgentDO.alarm's saveJob — re-reads and matches startedAt before writing.

Give the claim an identity (a claim token column, or CAS on the exact next_attempt_at the
claimer wrote) and have BOTH terminal writes require it plus status = 'pending'. Make attempts
a relative increment (attempts = attempts + 1) rather than a stale absolute.

Verification

  • A late markAttemptFailed against a row already delivered changes nothing (0 rows).
  • A late markDelivered against a row already dead changes nothing.
  • Two overlapping attempts increment attempts twice, so the dead-letter bound still holds.
  • Mutation-check: remove the guard and each test must fail — otherwise it isn't reproducing it.

Note on scope

The 5-minute re-execution window itself (consequence 1's first half) looks intentional — the
comment says a died-mid-flight attempt "simply becomes due again", trading possible re-execution
for recoverability. That trade is defensible and is not what this issue asks to change. The
corruption caused by the late writer is not part of that trade.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions