Found during review of #1092.
Every other terminal transition advances the revision
close_claim in crates/registry-scheduling/src/store.rs is what release and
cancellation go through:
UPDATE scheduling_claims SET state=$2, reason=$3,
revision=$4 + 1, closed_at=now(), changed_at=now()
WHERE claim_id=$1 AND state='active' AND revision=$4
Its callers then write the history event at that incremented revision.
The expiry sweep does not
expire_due_holds runs its own update:
UPDATE scheduling_claims SET state='expired', closed_at=now(), changed_at=now()
WHERE claim_id IN (...) RETURNING claim_id, revision
revision is absent from the SET list, so the RETURNING value is the revision the
row already had, and the loop below writes the expired history row at that value.
Result
An expired hold has its held event and its expired event at the same revision,
while a released hold has them one apart. Anything reading scheduling_history and
treating the revision as the ordering key, or as the identity of a distinct state,
sees the two events as one state. scheduling_history has no unique constraint on
(claim_id, revision), so nothing catches it at write time.
This is a ledger consistency wart rather than a capacity fault: capacity is already
correct because the snapshot query discounts expired holds by hold_expires_at
without waiting for the sweep, as the comment on expire_due_holds says.
Fix
Add revision=revision + 1 to the update and let RETURNING revision carry the new
value into the history insert, so the sweeper matches the lifecycle every other
closing writer follows.
Found during review of #1092.
Every other terminal transition advances the revision
close_claimincrates/registry-scheduling/src/store.rsis what release andcancellation go through:
Its callers then write the history event at that incremented revision.
The expiry sweep does not
expire_due_holdsruns its own update:revisionis absent from the SET list, so theRETURNINGvalue is the revision therow already had, and the loop below writes the
expiredhistory row at that value.Result
An expired hold has its
heldevent and itsexpiredevent at the same revision,while a released hold has them one apart. Anything reading
scheduling_historyandtreating the revision as the ordering key, or as the identity of a distinct state,
sees the two events as one state.
scheduling_historyhas no unique constraint on(claim_id, revision), so nothing catches it at write time.This is a ledger consistency wart rather than a capacity fault: capacity is already
correct because the snapshot query discounts expired holds by
hold_expires_atwithout waiting for the sweep, as the comment on
expire_due_holdssays.Fix
Add
revision=revision + 1to the update and letRETURNING revisioncarry the newvalue into the history insert, so the sweeper matches the lifecycle every other
closing writer follows.