Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,77 @@ any change to one appears here.
build (uv's CPython 3.14 on macOS) aborted inside `ensurepip`, so all five release fixtures
errored before a release was installed. The fixture now symlinks, as `python -m venv` does on
POSIX.
- **The Postgres store could hand out one attempt number twice** (`docs/SPEC-v0.7.md` §5.6).
0.6.1's renewal after `FAILED` read the record with a plain `SELECT` and then updated it on
`effect_key` and `state = 'failed'` alone. So a renewal planned against attempt *k* could land
after another process had renewed to *k+1*, run and failed, and write *k+1* a second time: two
dispatches, and two receipts, under one attempt number. The `UPDATE` is now also conditioned on
the attempt it was planned from, with the row count checked, and a stale renewal is refused with
`DuplicateEffect`. SQLite carries the same condition, where it was already unreachable because
`BEGIN IMMEDIATE` holds the read and the write together.
- **A stale Postgres write could put an older attempt number back** (`docs/SPEC-v0.7.md` §5.6).
0.6.1's compare-and-set under `resolve_effect`, `extend_lease`, `hold_continuation`, the kept
`AMBIGUOUS` write and the four transitions matched `effect_key`, `action_id` and `state`, and
wrote back the attempt number it had read. A caller that retries one `Action` reuses its
`action_id`, so the same `action_id` and state could come round again at a newer attempt between
the read and the write. A human's `ctrlrun resolve` decided on attempt 1 then wrote `FAILED` at
1 over attempt 2's unknown outcome, and the next renewal handed out attempt 2 a second time.
**On Postgres**, every write to an effect record is now also conditioned on the attempt it read,
and a write whose record moved is re-read rather than landed (SQLite's writes are already inside
the `BEGIN IMMEDIATE` that holds their read, and are unchanged). The fix for the renewal above
depends on this one: it holds only because the number can no longer move backwards. What this
closes is the race between a write's own read and its write. The window a *human* stands in is
longer, because `ctrlrun resolve` carries no attempt number: someone who inspected attempt 1 can
still resolve attempt 2's ambiguity. An attempt argument on the CLI would close that, and it is
not in this change.
- **A Postgres write that carried an outcome could be refused and drop it.** With the condition
above in place, a `commit_effect` or `mark_ambiguous` whose record moved under it wrote nothing
and raised, so the effect record, which is what gates the next renewal, said nothing about an
attempt that may have acted: a review measured a renewal to a third attempt with a committed
refund recorded on no record at all. Both are now re-issued once against the re-read, so the
outcome lands on the record as it stands. `begin_execution` and `fail_effect` are still refused
there, because `FAILED` asserts that nothing happened and the newer attempt may be running.
- **A refused write on a moved record claimed the wrong thing.** All of these were
`DuplicateEffect(state="in_progress")`, which means *another attempt holds a live reservation*,
and after a stale `resolve_effect` the record is `AMBIGUOUS` at a newer attempt, which is nobody's
reservation. The refusal now takes its type from what the re-read found, `AmbiguousEffect` or
`DuplicateEffect` with `committed` or `in_progress`, and its message names the move.
- **An unknown outcome could vanish when the store refused to record it**, on both backends and
since before 0.6. `Control` caught two store refusals around its outcome writes, and a record a
human resolved `FAILED` while the attempt was still running answers a third: an executor that
raised `TimeoutError` then produced no receipt and no `EXECUTION_AMBIGUOUS` event, and the caller
was handed a store error about its own effect key instead of its executor's exception. `Control`
now catches every `CTRLRunError` from an outcome write, writes the receipt and the event whatever
the store answered, names the refusal in both, and re-raises the caller's own exception. Nothing
is reconciled on a record the attempt could not mark.
- **Two bounds on the Postgres store's re-issues reset each other.** The stale re-issue and the
lost-commit re-issue of `docs/SPEC-v0.6.md` §4.3.2 each carry a flag that permits one attempt,
and neither passed the other's flag on, so a lost `COMMIT` inside a re-issue and a re-issue
inside a lost `COMMIT` alternated without end: a review drove both halves at once and reached
`RecursionError`, which is outside this library's closed set of errors, so no caller can
classify it and the record is left stranded. Both flags now travel through both re-issues.
- **A receipt for an outcome the store refused did not say what the executor had done.** An
executor that returned normally and one that raised `NotExecuted` produced identical receipts,
naming the store's refusal and nothing else. For the first the remote very likely acted and for
the second it very likely did not, and with the effect record carrying neither, that is the one
fact whoever runs `ctrlrun resolve` has to go on. The receipt now carries both.
- **A lapsed Postgres approval could be marked expired over a consumption.** `_expire` wrote
`status = expired` on `approval_id` alone, from a read that saw `granted` past `expires_at`, so a
consumption committing in between was overwritten and an approval that authorised a real effect
read `expired`. It is now conditioned on the status it read, as every other write on that table
already was.
- **After a lost `COMMIT`, a Postgres reservation could return an attempt number it did not
write.** Where a reservation's `COMMIT` was lost and the re-read found the write absent, 0.6.1
re-issued it and then returned the reservation it had first planned, discarding the re-issue's.
If another process had renewed, or inserted, and failed in between, the caller and its receipt
held attempt *k+1* while the record held *k+2*, and *k+1* was a number another dispatch had
already been handed. The reservation methods now return what the re-issue wrote.
- **A lost `COMMIT` on a Postgres renewal could take another process's reservation for its own.**
0.6.1's re-read accepted any `RESERVED` record carrying the renewal's `action_id` as proof the
commit had landed. `docs/SPEC-v0.6.md` §4.3.3 had already ruled that out on the insert path,
because `action_id` is caller-supplyable, and a caller that rebuilt the same `Action` renews
under the same one. Two processes then held one attempt. The renewal's re-read now applies the
same whole-row identity check, and a row that is not its own write is refused.

### Documentation

Expand Down
11 changes: 11 additions & 0 deletions docs/SPEC-v0.6.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,12 @@ The shape of one reservation, inside one transaction:
`DuplicateEffect(state=in_progress)`, because the record changed under us. SQLite already does
exactly this, and the `WHERE` clause is the same one.

*Amended by `SPEC-v0.7.md` §5.6 (its §9.6, item 8):* step 6's `UPDATE` is also conditioned on the
attempt the plan renewed from, and every later compare-and-set below (`_transition`, and the write
under `resolve_effect`, `extend_lease`, `hold_continuation` and §4.2.2's kept `AMBIGUOUS` write) on the
attempt it read, because each writes that number back and `action_id` and `state` alone can come round
again at a newer attempt. §12.3a there says what 0.6.1 did without it.

**The retry at step 5 is bounded at one**, and the bound is not a performance choice. A second
zero would mean the winner's record vanished between the re-read and the insert, which nothing in
this protocol can do — there is no `DELETE` on `effects` anywhere in the codebase — so a second
Expand Down Expand Up @@ -1017,6 +1023,11 @@ A store that takes any branch of §4.3.2 MUST say which one, on the `ctrlrun.pos
| `a2.row2.reissue` | Table A2: the record is still in a pre-state the operation may be issued from; the conditional `UPDATE` is re-issued |
| `a2.row3.refuse` | Table A2: anything else; back through the same predicate, which refuses |

*Amended by `SPEC-v0.7.md` §12.3a (its §9.6, item 8):* on a **renewal**, `a2.row1.landed` now requires
§4.3.3's whole-row identity, as `a1.row1.ours` does, rather than `RESERVED` under our `action_id`: a
second process renewing under the same `action_id` produced exactly that record. Anything else a
renewal's re-read finds, other than `FAILED`, is `a2.row3.refuse`.

**`a2.row2.reissue` does not mean "still ours", and saying so would claim more than the code
checks.** On a *transition* the row is ours — `action_id` and a pre-state in `expected`. On a
*renewal* — a reservation over a `FAILED` record, `v0.1 §5.4`'s one automatic retry — the pre-state
Expand Down
Loading
Loading