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

Two Screens, One Choice

~13 min · concurrency, optimistic-locking, sqlite, ui

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Two screens, one set

Dad reviews candidates wherever he happens to be. The web studio on a Mac and the iPhone and iPad app are the same WebUI: the app hosts it in WebKit and talks to the same engine. The architecture puts it plainly: selections are server-owned, they carry an optimistic revision, and web and iOS share saved choices. So picture the race. A batch review is open on both screens. Dad picks a candidate on the iPad. Later he turns to the Mac, where the page has been open for an hour, and picks a different candidate there.

A number that moves on every write

Every row in records has a version column that starts at 1. Every update bumps it in the same statement that writes the new body. A client that reads a record also reads its version, and sends that number back with its change. Store.update compares. If the stored version is not the one the client read, someone else wrote in between, and the store refuses with a 409: This record changed. Refresh before saving again.

This is optimistic concurrency. No lock is held while Dad looks at a screen, which may be for an hour. A lost race costs one refusal, and only the write that lost pays it.

Why BEGIN IMMEDIATE matters

The comparison and the write have to be one step. Studio.io hands every store call to asyncio.to_thread, so two choices can reach the store at the same moment on two threads. update opens with BEGIN IMMEDIATE, which takes SQLite's write lock before it reads anything. The second thread waits, within the connection's 10-second timeout, then reads the version the first one just wrote, and fails the comparison.

Leave that line out and the guard leaks. Python's sqlite3 module, in its default mode, opens a transaction only before a statement that writes, never before a SELECT. Both threads could read version 7, and both updates would land, because the UPDATE itself matches on the id alone.

Every editable record carries its version

What Dad editsRouteVersion
a set's selectionPATCH /api/sets/{id}/selectionrequired
an asset's label or archive flagPATCH /api/assets/{id}required
a set's or a batch's archive flagPATCH /api/sets/{id}, PATCH /api/batches/{id}required
a reference profile, a workflow templatePUT /api/souls/{id}/references, PUT /api/templates/{workflow}left out only for the first save

The last row covers a second race: two screens creating the first profile for the same soul. The save takes the studio's intent lock and looks for an existing profile. A request without a version is a claim that nothing is saved yet. If something is saved after all, the claim is wrong and the save is refused with Reference settings changed. Refresh first. If nothing is saved, a request that carries a version is just as wrong, and gets the same refusal. Templates follow the same rule.

A few writes carry no version: the worker moving its own tasks through their states, and a publication completing. The second one reaches every open screen. Completing a publication writes the set's new published map and live revision, which bumps the set's version, so a screen still holding the old number gets a 409 on its next choice and reloads.

The server guards its own merge

select reads the set, checks every chosen candidate (the right soul, and for a generated one, a finished task for that frame of this set), merges the new choices into the existing selection, and writes with expected=body.version. Between that read and the write there are calls to Pippa's registry. If another write lands in that window, the version has moved, and the merge built on the old read is refused. One number protects both the client's view and the server's own read, merge and write.

What the screen does with a 409

The Batches page sends the version of the set it holds. If the call fails, it shows the error, and if the message says the record changed, it reloads the sets so the next click carries the new version. It does not resend the choice. I think that is right: the iPad's choice might be the one Dad wanted, and he should see it before he overrides it.

One honest weakness. The page recognises a conflict by the word changed in the message, not by the 409 status. The conflict message on this path contains that word today. A rewording on the server would quietly stop the reload, and a status check would not care.

What a selection means, and why choosing is never publishing, is the opening lesson of Publishing Is One Way.

Code

cwkAvatars, backend/store.py: update, compare the version and write under one lock (verbatim, lines 92-102)·python
    def update(self, id: str, expected: int | None = None, **changes) -> dict:
        with self.tx() as db:
            db.execute("BEGIN IMMEDIATE")
            row = db.execute("SELECT * FROM records WHERE id=?", (id,)).fetchone()
            if row is None:
                raise StudioError("Record not found", 404)
            if expected is not None and row["version"] != expected:
                raise Conflict("This record changed. Refresh before saving again.")
            body = {**json.loads(row["body"]), **changes}
            db.execute("UPDATE records SET body=?,version=version+1,updated_at=? WHERE id=?", (json.dumps(body), now(), id))
        return self.get(id)
cwkAvatars, frontend/src/Batches.tsx: choose, send the version, reload on a conflict (verbatim, lines 94-111)·typescript
  async function choose(choices: Record<string, string>) {
    if (!collection) return;
    setBusy(true);
    try {
      await api(
        `/sets/${collection.id}/selection`,
        { version: collection.version, choices },
        "PATCH",
      );
      onReload();
    } catch (e) {
      error(e);
      // Another device chose first: refresh so the next click uses its version.
      if (e instanceof Error && e.message.includes("changed")) onReload();
    } finally {
      setBusy(false);
    }
  }
cwkAvatars, backend/studio.py: save_profile, a missing version claims nothing is saved yet (verbatim, lines 279-288)·python
        async with self.intent_lock:
            existing = await self.profile(soul)
            if existing["id"]:
                if body.version is None:
                    raise Conflict("Reference settings changed. Refresh first.")
                await self.io("update", existing["id"], expected=body.version, **changes)
            else:
                if body.version is not None:
                    raise Conflict("Reference settings changed. Refresh first.")
                await self.io("add", "profile", parent=soul, **{"variants": {}, **changes})

External links

Exercise

Pick one record in your app that two devices or two browser tabs can edit. Add a version column, return it with every read, and require it on every write. Make the write a compare-and-swap that refuses with 409 when the stored version differs. Open the same record in two tabs, save from both, and decide what the losing tab shows and what it lets the person do next.
Hint
Two ways to make the compare-and-swap atomic in SQLite: BEGIN IMMEDIATE, then read, compare and write; or a single UPDATE ... WHERE id = ? AND version = ? followed by a check that exactly one row changed. On the client, branch on the status code, not on the words of the message.

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.