Measured while implementing the ack() status precondition on #11453 (PR #11858). Filed unassigned; not touched in that PR because closing it requires a signature change, which is a contract decision rather than a repair.
What the compare-and-set does and does not close
SqlNotificationOutbox.ack now re-states its precondition in the write:
where: { id, status: 'in_flight' }, multi: true
That closes the defect #11453 named — an ack on an unclaimed pending row, or on an already-terminal one, matches nothing and is refused.
It cannot close the ownership case. ack(id: string, result: AckResult) takes no nodeId, so the predicate can only ask "is this row claimed?", never "is this row claimed by the caller acking it?". The reachable sequence:
- node A claims row R and starts a send;
- the send outruns
claimTtlMs; - another node's
claim() reaps R back to pending and re-claims it — R is in_flight again, claimed_by = B; - node A finishes and acks.
status = 'in_flight'matches, so A's outcome is written over B's live attempt.
The row carries the answer (claimed_by), and the contract has no way to hand it to the write. MemoryNotificationOutbox has the identical gap.
Why it was not just fixed
Adding the claim owner to ack changes a declared member of a declared interface, and there is more than one shape:
ack(id, result, opts?: { nodeId }) — additive, but an optional owner is unenforced by construction: every existing caller keeps the current semantics and the guarantee is only as good as the callers that opt in.ack(id, result, nodeId) required — declared = enforced, and a breaking signature change for both implementations plus every call site.- Carry the claim token on the record —
claim() already returns the row; the dispatcher could pass back what it was given, which makes the owner un-forgeable without the caller having to know its own nodeId.
The third is the most interesting and the least explored. It wants deciding with the cancellation surface deferred by the #11454 ruling rather than in isolation, since both are questions about what an outbox row's lifecycle members should carry.
Blast radius today
Bounded but real: the window is a send slower than claimTtlMs (default lockTtlMs * 2), and the consequence is a double delivery whose terminal state records one of the two attempts. The dispatcher's per-partition cluster lock does not help — the reap is what re-opens the row, and any node may hold the partition after a TTL lapse.
Blocked-by: #11453
Links
Measured while implementing the
ack()status precondition on #11453 (PR #11858). Filed unassigned; not touched in that PR because closing it requires a signature change, which is a contract decision rather than a repair.What the compare-and-set does and does not close
SqlNotificationOutbox.acknow re-states its precondition in the write:That closes the defect #11453 named — an
ackon an unclaimedpendingrow, or on an already-terminal one, matches nothing and is refused.It cannot close the ownership case.
ack(id: string, result: AckResult)takes nonodeId, so the predicate can only ask "is this row claimed?", never "is this row claimed by the caller acking it?". The reachable sequence:claimTtlMs;claim()reaps R back topendingand re-claims it — R isin_flightagain,claimed_by = B;status = 'in_flight'matches, so A's outcome is written over B's live attempt.The row carries the answer (
claimed_by), and the contract has no way to hand it to the write.MemoryNotificationOutboxhas the identical gap.Why it was not just fixed
Adding the claim owner to
ackchanges a declared member of a declared interface, and there is more than one shape:ack(id, result, opts?: { nodeId })— additive, but an optional owner is unenforced by construction: every existing caller keeps the current semantics and the guarantee is only as good as the callers that opt in.ack(id, result, nodeId)required — declared = enforced, and a breaking signature change for both implementations plus every call site.claim()already returns the row; the dispatcher could pass back what it was given, which makes the owner un-forgeable without the caller having to know its ownnodeId.The third is the most interesting and the least explored. It wants deciding with the cancellation surface deferred by the #11454 ruling rather than in isolation, since both are questions about what an outbox row's lifecycle members should carry.
Blast radius today
Bounded but real: the window is a send slower than
claimTtlMs(defaultlockTtlMs * 2), and the consequence is a double delivery whose terminal state records one of the two attempts. The dispatcher's per-partition cluster lock does not help — the reap is what re-opens the row, and any node may hold the partition after a TTL lapse.Blocked-by: #11453
Links
ack()on an unclaimedpendingrow silently succeeds in both implementations #11453 / PR fix(service-messaging): enforce ack()'s claimed-row precondition in both outbox implementations #11858 (the precondition that made the residue visible)whereon a by-idupdateis silently inert — the extra predicate keys never reach the driver, andSqlHttpOutbox.redeliver's status guard is one of them #11009 (a by-id predicate silently discarded — the reason the ack write rides the predicate path at all)