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

The Same Request Twice

~14 min · idempotency, transactions, sqlite, retries

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

A reply that never arrived

A generation request can be large. The architecture's own example: two 15-frame collections, two providers and five candidates each come to 300 tasks across two requests of 150, one per set. Now picture the reply getting lost. A phone on a slow uplink, a proxy that times out, a harness that retries a brain's tool call. The client cannot tell "never arrived" from "arrived, and the answer was lost". If retrying creates a second batch, every hiccup in the network becomes a second bill.

The caller names the request

Every generation carries a request_id of 1 to 100 characters, chosen by the caller. The server never invents one, because only the caller knows whether two posts are the same intent. The web studio mints it with crypto.randomUUID() when Dad opens the review step, keeps the whole request body frozen in that preview, and the submit button sends the frozen request. If the reply is lost, the preview is still open, and pressing submit again sends the same id.

A fingerprint beside the id

An id alone is not enough. A client bug could reuse an id for a different request, and the server would hand back the wrong batch with a straight face. So the requests table stores a fingerprint next to each id: the SHA-256 of the payload, serialised as JSON with sorted keys so the order of fields cannot change the hash. Three outcomes follow.

  • A new id: plan the batch and create it.
  • A known id, same fingerprint: return the record it created the first time. No new plan, no new tasks.
  • A known id, different fingerprint: 409, The request ID already belongs to different input.

The remix test drives all three. It submits a request for two providers, three candidates each, over a two-frame set, and counts 12 tasks. It submits the same request again and gets the same batch id back. Then it changes only the prompt under the same id and expects Conflict.

The whole tree in one transaction

A batch is a parent record with one child task per image. create_intent writes all of it at once, and its docstring says so in one line: commit the idempotency key, intent and complete task tree together. It opens with BEGIN IMMEDIATE, checks the request id inside that write transaction, inserts the batch, inserts every task with the batch as its parent, inserts the request binding, and commits once.

Consider the alternatives. Commit the batch, then the tasks: a crash in between leaves a batch with half its tasks, and the worker would run half a plan. Commit the tasks, then the binding: a crash leaves a complete batch the retry cannot find, so the retry builds a second one. A single transaction closes both gaps. A plan may hold up to 600 tasks by default (AVATARS_MAX_BATCH_TASKS), and they all commit together or not at all.

BEGIN IMMEDIATE takes the write lock before the check. Two threads with the same request id cannot both see "not yet" and both insert. The second waits for the lock, then finds the first one's binding and returns its batch.

Proving the rollback

One test is worth reading line by line. It asks create_intent for a batch with two tasks, and the second task's body holds a bare Python object(), which json.dumps cannot serialise. By the time that TypeError fires, the batch row and the first task have already been inserted. The test then asserts that there are no batches, no tasks, and no binding for the request id. A retry with a good payload starts from nothing.

Network outside, transaction inside

Planning a batch talks to Pippa, who decides which subjects a caller may see, and to Ember, which knows the generators and resolves the automatic route. None of that may happen inside a SQLite transaction. So generate works in three steps under the studio's intent_lock, an asyncio lock: check the request id with a plain read, build the plan over the network, then call create_intent, which checks again inside its own transaction before writing. The early check spares a retry a second round of planning. The late check is the one that guarantees.

Principle: Bind the key to its intent in the same commit that records the intent. Anything committed separately is a gap a retry can fall through.

The same question, elsewhere in the studio

  • Publication goes through create_intent as well, so a publish whose reply was lost resumes the same publication. Publishing Is One Way tells that story.
  • Photo uploads can carry a client_key. Saving an upload writes a file first, and a file cannot join a SQLite transaction, so this path uses the store's two smaller calls: look up the request, save the asset, then bind the request to it, all under an upload lock. The Studio in Your Pocket covers why the phone needs it.
  • The worker asks the question of its own steps. Before it fetches a finished image from Ember, finish looks for an asset that already carries this task's id and reuses it. A worker that died after saving the image does not save it twice.

Code

cwkAvatars, backend/store.py: create_intent, key, intent and task tree in one commit (verbatim, lines 124-140)·python
    def create_intent(self, request_id: str, payload: dict, kind: str, body: dict, *, parent="", children=()) -> dict:
        """Commit the idempotency key, intent and complete task tree together."""
        fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
        record_id, at = identity(), now()
        with self.tx() as db:
            db.execute("BEGIN IMMEDIATE")
            existing = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
            if existing:
                if existing["fingerprint"] != fingerprint:
                    raise Conflict("The request ID already belongs to different input.")
                return self.unpack(db.execute("SELECT * FROM records WHERE id=?", (existing["record_id"],)).fetchone())
            entries = [(record_id, kind, parent, body)]
            entries.extend((identity(), child_kind, record_id, child_body) for child_kind, child_body in children)
            for id, entry_kind, entry_parent, entry_body in entries:
                db.execute("INSERT INTO records(id,kind,parent,body,created_at,updated_at) VALUES(?,?,?,?,?,?)", (id, entry_kind, entry_parent, json.dumps(entry_body), at, at))
            db.execute("INSERT INTO requests VALUES(?,?,?)", (request_id, fingerprint, record_id))
        return self.get(record_id)
cwkAvatars, backend/tests/test_studio.py: a failure on the third insert leaves nothing behind (verbatim, lines 204-209)·python
def test_intent_tree_rolls_back_everything_on_partial_insert_failure(tmp_path):
    store = Store(tmp_path)
    with pytest.raises(TypeError):
        store.create_intent("intent", {"prompt": "x"}, "batch", {}, children=[("task", {"good": True}), ("task", {"bad": object()})])
    assert store.list("batch") == store.list("task") == []
    assert store.request("intent", {"prompt": "x"}) is None
cwkAvatars, backend/studio.py: generate checks early, then plans over the network outside any transaction (verbatim, lines 548-554)·python
    async def generate(self, body, actor=None):
        payload = {**body.model_dump(), "actor_soul_id": actor}
        async with self.intent_lock:
            existing = await self.io("request", body.request_id, payload)
            if existing:
                return existing
            plan = await self.plan(body, actor)

External links

Exercise

Pick an endpoint in your own app that creates a parent record with children: an order with line items, a job with steps. Add a caller-supplied request id and a requests table that stores a SHA-256 fingerprint of the sorted-key JSON payload. Write all rows and the binding in one BEGIN IMMEDIATE transaction. Then write three tests: the same id and payload twice returns one parent; the same id with a changed field is refused; a serialisation failure on the last child leaves no parent, no children and no binding.
Hint
In Python's sqlite3, execute "BEGIN IMMEDIATE" yourself inside the connection's context manager; the context manager commits on success and rolls back on any exception. An unserialisable object() in one child body is the cheapest way to force a failure halfway through the inserts.

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.