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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project are recorded here. The format follows Keep a

## [Unreleased]

## [0.3.1] - 2026-09-15

### Fixed

- Acquisition identity survives renewal. 0.3.0 used the record oid as the acquisition's identity, so an `extend` inside a `with` changed the oid and the wrapper's own release then reported "superseded", leaving the lock until expiry. Records now carry an `acquisition` id, minted by a claim and kept by `extend` and by a child admission's rewrite of the parent; `release --acquisition <id>`, `sem release --acquisition <id>` and `with` release by it. `record` stays as the oid of the current record version. Reproduced first: `with` running `extend` inside its command, then a check that the path is free.

### Added

- A second forced interleaving in the suite: a renewal committed between a release's read and its commit; the release re-plans and the renewed lock is gone.
- README carries measured timings on 500 locks and says plainly that process count is not time.

## [0.3.0] - 2026-09-15

The correctness release. An outside review of 0.2.1 found five defects under the guarantees and asked three questions; each defect was reproduced as a failing test before it was fixed, and README's "The contract" section carries the answers.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,11 @@ In summary, the output is the API, the schema is its contract, and the tests are

An outside review of 0.2.1 found the guarantees running ahead of the implementation in five places and asked three questions. The fixes shipped in 0.3.0; the answers are the contract.

**What a successful acquisition authorises, and how it is identified.** A claim admits one *acquisition*: a record blob whose object id the claim line returns as `record`. The job id is a label a person or an orchestrator chooses; it can be reused, and a later claim under the same job replaces the record. `release --job X --record <oid>` releases that acquisition and only that one: if the job now holds a different record, the answer is `{"event":"nothing","reason":"superseded"}` and nothing moves. `with` remembers the record it acquired and releases by it, so an invocation that outlives a re-claim of its job name cannot release someone else's lock. The same holds for semaphore slots.
**What a successful acquisition authorises, and how it is identified.** A claim admits one *acquisition*, and three names apply to it, kept distinct on purpose. The **job id** is a label a person or an orchestrator chooses; it can be reused, and a later claim under the same job is a new acquisition that replaces the old one. The **acquisition id** (`acquisition` on the claim line) is minted by the claim and kept by every rewrite of the record: `extend`, and the family bump a child admission performs on a parent. The **record** (`record`) is the object id of the current version of that record, and changes on every rewrite. `release --job X --acquisition <id>` releases that acquisition and only that one, across any number of renewals; `--record <oid>` releases only if the record is exactly that version. If the job now holds a different acquisition, the answer is `{"event":"nothing","reason":"superseded"}` and nothing moves. `with` remembers the acquisition it made and releases by it, so an invocation that outlives a re-claim of its job name cannot release someone else's lock, and one whose command renewed the lock still releases it. Semaphore slots carry the same two ids.

**What binds the membership you observed to the decision you commit.** Every write is compiled into one transition per ref with the old value it expects, and sent as one transaction; a stale expectation fails the whole transaction and the command re-reads and re-plans a bounded number of times. Family membership is bound through the parent's own record: admitting a child rewrites the parent's blob with a bumped `family` generation and moves the parent's refs to it, so a release or sweep that planned against the old parent fails when a child was admitted meanwhile, and re-plans with the child in view. Semaphore capacity is bound through the semaphore's generation ref the same way. A snapshot is a cached read taken under one `for-each-ref`; it is never treated as a consistent cut, which is why every write carries expectations.

**What `parent` means.** Ownership plus lifetime, not dependency ordering. A child is admitted only under a live parent held by the same holder, checked at planning and, through the generation bump, at commit. The child is released or swept whenever the parent is, by any command, including a claim that evicts an expired parent. Expiry is not inherited: a child keeps its own `expires`, and a parent's expiry ends the family. Renewing a parent (`extend`) keeps its family. Recreating a job name after its release makes a new record with a fresh family, unrelated to the old one.
**What `parent` means.** Ownership plus lifetime, not dependency ordering. A child is admitted only under a live parent held by the same holder. Liveness and holder are checked at planning time; what the generation bump adds at commit time is that the parent's record is unchanged since that check, so a release, a renewal or another child cannot have slipped in between. The bump does not re-check the clock: a parent that expires during the microseconds between planning and commit is still bumped, and its family ends at the next sweep or claim over it. The child is released or swept whenever the parent is, by any command, including a claim that evicts an expired parent. Expiry is not inherited: a child keeps its own `expires`, and a parent's expiry ends the family. Renewing a parent (`extend`) keeps its family. Recreating a job name after its release makes a new record with a fresh family, unrelated to the old one.

**What a path identifies.** The lexical form after normalisation: leading `./`, empty segments, `.` segments and a trailing `/` are removed; absolute paths and `..` are refused. `dir//file`, `dir/./file` and `dir/file/` are one key. Case, symlinks and hard links are not resolved, and `dir/` does not cover `dir/file` (#6). That is a policy, stated, not an omission.

Expand Down Expand Up @@ -441,7 +441,7 @@ git config --local core.hooksPath scripts/hooks # pre-commit lints, pre-push t
- One machine. The store is local; a shared remote would need a fetch before every claim and is out of scope.
- `git rev-parse --path-format=absolute` and `update-ref --stdin` transactions need git 2.31 or newer.
- bash 4 or newer: the store snapshot uses associative arrays. macOS's `/bin/bash` is 3.2; the script's shebang finds a newer bash on `PATH` (Homebrew's, for instance).
- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns.
- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns. Process count is not time: the snapshot is parsed in bash, so work grows with the store. Measured on 500 locks (macOS, bash 5.3): `check`, `claim` and `show` each about 0.07 s; `list`, which renders every record, 5.8 s. A store of hundreds of live locks is fine; one of thousands wants #11's split and a leaner `list`.
- Every command reads the store once, plans, then commits with expectations. A racer can win in between; the transaction then fails and the command re-plans or reports who won. That is the designed outcome, not a gap.
- The tests are bounded conformance evidence. Twenty racers and one forced interleaving are what the suite shows; they are not a proof over every schedule.

Expand Down
Loading
Loading