Skip to content
C.W.K.
Lesson 01 of 05 · published

Compare-and-Swap on Bytes

~16 min · compare-and-swap, optimistic-concurrency, sha-256, lost-update

Level 0Loose Note
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Lost Update

Two sessions read the same note at 10:00. Each adds a paragraph. Each saves. With a plain file, the second save silently replaces the first, and one session's paragraph is gone without anyone being told. This is the lost update, the oldest bug in shared storage, and a soul vault with many writers (the WebUI, coding sessions on several Macs, scheduled jobs) meets it constantly.

Soul Vault's answer is optimistic concurrency: compare-and-swap. A writer says not only what the note should become but also what it believes the note is now. If that belief is out of date, the write is refused.

A Revision Is a Hash

The belief is expressed as a revision, and in Soul Vault a revision is simply the SHA-256 of the note's bytes. Every read returns it. Every write must hand one back as expected_revision. That choice has a nice property: a revision is not an opaque counter the store invents, it is something any reader can compute from the bytes it holds. Two readers holding identical bytes hold the same revision, whatever store served them.

Inside the root's write_batch, under the vault's lock, every change is checked against the current bytes:

  • An update to a note that does not exist: note_not_found.
  • An update whose expected revision differs from the hash of the current bytes: revision_conflict, with the current revision attached in detail and a cause that ends with the instruction: "reread and rebase".
  • A create (a note that must not exist yet) over a note that does exist: already_exists, again with the current revision.

Only when every change in the batch passes does the template call the child's _apply. A batch is all or nothing: one stale revision refuses the whole batch.

The Second Life Problem

Creates have a subtler race. Session A lists a folder, sees that memory/2026-11-12-x.md does not exist, and goes off to compose it. Meanwhile session B creates that note, and session C deletes it. When A finally writes, the note is absent again, so a plain "must not exist" check passes, and A's create lands on top of a history it never saw.

Contract v5 closed that with the absent token. When a caller finds a note absent, it can ask absent_token(note) and keep the answer, then pass it as the expected revision of its create. In the Delta store the token encodes the sequence number of that path's last recorded event (absent:s<seq>). If the note was created and deleted in between, the sequence moved, the token no longer matches, and the create is refused: "it was created and deleted in between ... read again and decide again". From a Mac that cannot reach the canonical store, the token is deliberately one that names no state of it, so a create carrying it later is a conflict and its caller reads again.

Proved Under Load

Before the cutover, a load drill ran against a scratch copy of the real vault: 141 writes acknowledged and none missing from history, and a burst of 12 concurrent writes to one note that landed exactly 1 and refused the other 11 as conflicts. That second number is the whole point of this lesson. Eleven writers were told the truth ("someone got there first; reread") instead of eleven paragraphs disappearing.

Code

The compare-and-swap loop inside write_batch (trimmed from soulvault/root.py)·python
for change in checked:
    current = self._read_bytes(change.note)
    current_rev = sha256_hex(current) if current is not None else None
    if change.op == "put" and not change.must_exist:          # a create
        if current is not None:
            raise VaultError("already_exists", f"{change.note.uri} already exists",
                             detail={"current_revision": current_rev})
        if change.expected_revision is not None:              # an absent token
            token = self._absence_token(change.note)
            if change.expected_revision != token:
                raise VaultError("revision_conflict",
                    f"{change.note.uri} is absent, but not as the caller saw it "
                    f"({change.expected_revision}): it was created and deleted in "
                    f"between (now {token}); read again and decide again")
        continue
    if current is None:
        raise VaultError("note_not_found", f"{change.note.uri} does not exist")
    if change.expected_revision != current_rev:
        raise VaultError("revision_conflict",
            f"{change.note.uri} is at {current_rev[:12]}, not the expected "
            f"{str(change.expected_revision)[:12]}; reread and rebase",
            detail={"current_revision": current_rev})
revisions = self._apply(checked, actor, str(request_id), reason)
Compare-and-swap from the command line·bash
# read: the revision is the sha256 of the bytes you got
soul-vault read soul://pippa/memory/example.md --json | jq -r .revision

# write: hand that revision back; stdin is the new text
soul-vault write soul://pippa/memory/example.md --expected <revision> \
  --actor pippa --why "add the evening's correction" < new.md

# a stale revision is refused, nothing is written:
# revision_conflict: soul://pippa/memory/example.md is at 3f9a1c..., not the expected 77be02...; reread and rebase

External links

Exercise

Add compare-and-swap to a file-backed store of yours: every read returns the SHA-256 of the bytes, every write requires the expected hash, and a mismatch raises a conflict that carries the current hash. Then reproduce the lost update with two concurrent writers before and after the change, and run a burst of 12 writers against one key to confirm exactly one wins.
Hint
The check and the write must happen under one lock, or two writers can both pass the check before either writes. Take the lock first, read and compare inside it, then write.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.