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

Stage, Back Up, Swap Whole

~15 min · filesystem, atomicity, crash-recovery, python

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

The JPEG that would have won

Pippa serves a frame by name, without its extension. The route /avatars/{who}/{preset}/{emotion} walks a fixed list, .jpeg, .jpg, .png, .webp, and serves the first file that exists. The soul route does the same, in the soul's root and its default folder. Now picture the obvious way to publish: copy the new default.png into the live folder. If the folder still holds the old default.jpg, Pippa keeps serving the old face. Nothing fails, nothing logs, and the publication looks applied. The architecture names the trap in one line: Old JPEG files must not shadow newly installed PNGs.

The answer is to never merge into a live folder. Pippa builds the complete new folder somewhere else and replaces the old one whole. Its revision function agrees with its server, too: frame_files walks the extensions in reverse, so when two files share a frame name, the one the route would serve is the one that gets hashed.

Check everything before touching anything

publish runs its checks in an order that keeps the live folder out of reach until all of them pass:

  1. The request id has a safe shape, and a known id with the same fingerprint goes straight to its receipt.
  2. The target resolves from the registry: a valid channel, a slug name, an active subject with an avatar root.
  3. The live revision equals the expected one.
  4. The set is complete enough: at least one frame, and default among them.
  5. Every source path resolves inside Avatars' originals directory, is an image file, and still hashes to its recorded SHA-256.
  6. Every subject is active in the registry now, and a public soul may not receive a protected subject. Protection Follows the Pixels covers that rule.

A refusal at any step leaves Pippa's avatar folders exactly as they were. The Pippa test for protection checks precisely that: the refused publish raises, and the old file still holds its old bytes.

Back up first, and check the backup

The backup goes to avatar-backups/studio/<request_id>/previous, a tree beside Pippa's avatar folders, outside the routes that serve avatars. It is copied only if it is not already there, so a retry never overwrites the first backup with something newer. Then its revision must equal the live revision, or the publish stops with Backup verification failed. A backup nobody checked is a hope.

Stage a whole new directory

The stage is a fresh .avatars-stage-<uuid> in the same parent as the target. Each frame is copied in as its frame name with the original's extension, then hashed again: Staged original verification failed if a byte changed on the way. One older layout needs care. A soul whose default frames sit directly in its root keeps its other collections as subfolders there, so for that layout the stage first receives every subfolder and every non-image file, and none of the old images. The lineage file is written into the stage last, and then a receipt marked prepared records every path the swap will touch.

Two details make this safe. The stage shares the target's parent, so the rename that installs it stays on one filesystem, where Python documents os.replace as atomic. And the dot-prefixed names never match the slug pattern this module uses for frames and collections, which must start with a letter or digit, so its own discovery never mistakes a stage or a displaced folder for a collection.

Two renames and a receipt

os.replace cannot replace a directory that has files in it, so the swap takes two renames: the live folder moves aside to .avatars-old-<uuid>, then the stage moves into its place. Then the installed revision is checked against the receipt (Installed image verification failed otherwise), the receipt is marked applied in both places, and the displaced folder is removed, because the verified previous tree already sits in the backup.

For the instant between those two renames there is no live folder, and a request that landed exactly then would get a 404. The design does not hide that gap; it makes every crash inside it recoverable. finish_publication looks at the disk and continues from wherever it stopped:

The process diedOn diskThe same request again
after the prepared receipt, before any renameold live folder, complete stagemoves the old aside, moves the stage in, verifies
between the two renamesno live folder; stage and displaced foldermoves the stage in, verifies
after both renames, before the receipt said appliednew live folder, displaced foldersees the new revision, verifies, marks applied
and the stage was lost or damagedno usable stageputs the displaced folder back if the live one is gone, then refuses: the previous set was preserved

One more guard sits at the top of that function. If the live folder holds a revision that is neither the old one nor the new one, someone else published in between, and the resume refuses with The interrupted publication conflicts with a newer live set instead of overwriting them. Pippa's own test kills the process right after the old folder moves aside, finds no live folder, runs the same publish again and gets an applied receipt.

Principle: when you cannot make a change atomic, write down the plan first, and make every step safe to repeat. A crash becomes a pause.

Code

cwkPippa, backend/services/avatar_studio.py: publish, stage the whole set, re-hash, write the lineage and the prepared receipt (verbatim, lines 253-276)·python
        target.parent.mkdir(parents=True, exist_ok=True)
        staging = target.parent / f".avatars-stage-{uuid4().hex}"
        staging.mkdir()
        # Legacy guest defaults live directly in their soul root. Preserve
        # unrelated presets/On Air directories when activating that layout.
        if target == avatar_root(entry) and target.exists():
            for child in target.iterdir():
                if child.is_dir():
                    shutil.copytree(child, staging / child.name)
                elif child.suffix.lower() not in EXTENSIONS and child.name != "avatar-lineage.json":
                    shutil.copy2(child, staging / child.name)
        for frame, source in checked.items():
            shutil.copy2(source, staging / f"{frame}{source.suffix.lower()}")
            if digest(staging / f"{frame}{source.suffix.lower()}") != files[frame]["sha256"]:
                raise ValueError("Staged original verification failed")
        expected = revision(frame_files(staging))
        # Provenance travels with the set for public export policy, but is
        # deliberately not an image and never part of frame discovery.
        manifest["revision"] = expected
        atomic_write_text(staging / "avatar-lineage.json", json.dumps(manifest, indent=2))
        displaced = target.parent / f".avatars-old-{uuid4().hex}"
        receipt = {"request_id": request_id, "fingerprint": fingerprint, "status": "prepared", "soul_pk": body["soul_pk"], "channel": body["channel"], "name": body["name"], "previous_revision": previous, "revision": expected, "backup": str(backup), "target": str(target), "staging": str(staging), "displaced": str(displaced)}
        atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
        return finish_publication(receipt, receipt_path)
cwkPippa, backend/services/avatar_studio.py: finish_publication, two renames that resume after any crash (verbatim, lines 165-195)·python
def finish_publication(receipt: dict, receipt_path: Path) -> dict:
    """Resume a prepared activation after any crash between directory renames."""
    if receipt.get("mode") == "adopt":
        target, backup = Path(receipt["target"]), Path(receipt["backup"])
        if revision(frame_files(target)) != receipt["revision"]:
            raise ValueError("The legacy copies changed before adoption completed")
        atomic_write_text(target / "avatar-lineage.json", json.dumps(receipt["manifest"], indent=2))
        receipt["status"] = "applied"
        atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
        atomic_write_text(backup / "receipt.json", json.dumps(receipt, indent=2))
        return receipt
    target, staging, displaced, backup = (Path(receipt[key]) for key in ("target", "staging", "displaced", "backup"))
    current = revision(frame_files(target)) if target.exists() else None
    if current != receipt["revision"]:
        if current is not None and current != receipt["previous_revision"]:
            raise ValueError("The interrupted publication conflicts with a newer live set")
        if not staging.exists() or revision(frame_files(staging)) != receipt["revision"]:
            if not target.exists() and displaced.exists():
                os.replace(displaced, target)
            raise ValueError("The prepared set is unavailable; the previous set was preserved")
        if target.exists():
            os.replace(target, displaced)
        os.replace(staging, target)
    if revision(frame_files(target)) != receipt["revision"]:
        raise ValueError("Installed image verification failed")
    receipt["status"] = "applied"
    atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
    atomic_write_text(backup / "receipt.json", json.dumps(receipt, indent=2))
    if displaced.exists():
        shutil.rmtree(displaced)  # the verified previous tree remains in backup
    return receipt
cwkPippa, backend/tests/test_avatar_studio.py: the old JPEG is gone, its bytes live in the backup, a repeat returns the same receipt (verbatim, lines 66-79)·python
def test_publication_preserves_backup_native_bytes_and_idempotency(authority):
    body, target, entries = authority
    receipt = studio.publish(body)
    assert (target / "default.png").read_bytes() == b"native candidate bytes"
    assert not (target / "default.jpg").exists()
    assert (Path(receipt["backup"]) / "previous/default.jpg").read_bytes() == b"original image bytes"
    assert studio.publish(body) == receipt
    manifest = json.loads((target / "avatar-lineage.json").read_text())
    assert manifest["owner"] == "cwkAvatars"
    assert manifest["role"] == "published-copy"
    assert manifest["collection_id"] == "collection"
    assert "path" not in manifest["frames"]["default"]
    with pytest.raises(ValueError):
        studio.publish({**body, "expected_revision": "different"})

External links

Exercise

Write an install function for a directory of files your app serves. It verifies inputs, backs up the live directory and checks the backup's hash, builds a complete stage beside the target, writes a receipt listing every path, then swaps with two renames and verifies. Add a test hook that raises after the first rename. Run the install, let it crash, run it again with the same request id, and assert that the live directory ends up complete and the backup still holds the old one.
Hint
Keep the stage in the target's parent directory so both renames stay on one filesystem. Make the resume decide from what is on disk (which revision the live directory has, whether the stage still exists) rather than from a step counter, so it works no matter where the crash happened.

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.