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

Journal First, Mirror Second

~14 min · journal, jsonl, event-log, fsync, python

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Two files, and only one of them is true

Sizzle keeps its whole record in one data root, a directory set aside for the engine's data and kept apart from its code. The family registry names that root and backend/settings.py reads it, so a test or a second engine can point somewhere else with SIZZLE_DATA_DIR. Inside it, four things live side by side:

PathWhat it holdsIf it disappeared
journal.jsonlOne JSON event per line, appended and never rewrittenThe record is gone
mirror.sqlite3The tables every route readsThe next start builds it again
photos/Each photo's original bytes and two derived JPEGsPlates keep their events, and the photo route answers 404
staged/Photos sent ahead of the plate that will claim themA plate naming them is refused until they are sent again

The house sign puts the rule in one line: the journal is the record, and the mirror is disposable. Every durable fact is an event in the journal first. The SQLite file is a view of those events that the API can query quickly. This track is about that split, and about the small amount of code that keeps it honest.

One write path

There is exactly one function in the engine that changes the record: Sizzle.record in backend/domain.py. It takes an event type and its data, appends the event to the journal, applies the same event to the mirror, and returns it. A capture, a reading, a class proposal, Dad's correction, an adoption and every turn of a conversation all pass through it. The house sign lists it as invariant 1 and gives the grep that proves it: rg 'record\("' backend finds every writer.

The lock around it is a re-entrant threading.RLock. That detail matters in lesson 4. capture takes the same lock to re-check a request id and then calls record while still holding it. A plain lock would deadlock right there.

What one append does

Journal.append builds the envelope that every event shares: a ULID id, the dotted type, at as UTC with milliseconds and a trailing Z, and the event's own data. It serialises that with compact separators, so one event is one line, and with ensure_ascii=False, so a Korean principle name stays readable when you open the file. Then, under the journal's own lock, it opens the file in append mode, writes the line and a newline, flushes Python's buffer and calls os.fsync. Only after that call comes back does append return.

An fsync on every event sounds expensive, and in a chat log streaming tokens it would be. Sizzle's events are human-paced: a plate, a reading, a button Dad pressed. Paying for an fsync on each one costs almost nothing here. What it buys is narrower than it sounds, though. The engine runs on macOS, and Apple's fsync man page says the call moves the data to the drive, while the drive may keep it in its own cache and write it out later, even out of order. Only the F_FULLFSYNC fcntl asks the drive to flush that cache, and the journal does not use it. So a returned append means the operating system handed the bytes to the drive. It does not promise they survive a sudden power cut, or that lines reach storage in the order they were written. For a record of plates that is a fair trade, as long as you know it is one.

Why the journal goes first

Look at the order inside record. If the process dies after the append and before the apply, the event is in the journal and the mirror never saw it. The next start replays the journal, so the event appears then. Nothing is lost. Reverse the order and the same crash leaves a mirror row with no event behind it. The next start deletes the mirror and replays the journal, and the change Dad already saw quietly disappears.

Journal-first has one consequence you have to design for. Once an event is in the journal, every future start will apply it again. So the journal should only ever receive events the mirror can accept. That is why the checks live before record: DomainError for an unknown plate kind, a capture with no photos, a class that does not exist, a correction with no value. Lesson 4 shows what one event the mirror refuses does to every later start.

Principle: Write each fact once, in the place you never rebuild. Everything derived from it is a view you can throw away.

Never edited, never removed

The journal's docstring says it plainly: an event is never edited or removed. A correction is a new correction.added, an adoption is a new class.adopted, and a re-read is a new reading.recorded. Nothing in the engine deletes an event, and there is no route that could. That has an everyday cost the house sign names: a test plate belongs in a test engine. Live checks run with SIZZLE_DATA_DIR pointed at a scratch directory and SIZZLE_PORT=7690, because a plate captured into Dad's journal stays there.

Where this shape comes from

Sizzle did not invent the split. Its architecture doc lists Forge, Vesta and Waystone as engines with the same shape: an append-only JSONL journal as ground truth and a derived SQLite mirror recovered by purge and replay. cwkPippa's own chat record follows the same rule, and pippa-quest's track on JSONL ground truth tells that story from Pippa's side: Dad never sees something the JSONL does not already hold.

The mirror itself is plain Python sqlite3: one connection, sqlite3.Row as the row factory, check_same_thread=False behind the store's own lock. If those words are new, sqlite-quest's Python sqlite3 track covers each one, and its Writes, Transactions, and WAL track covers what a commit actually promises. This track uses SQLite as a fast cache that can be rebuilt at will.

Code

cwkSizzle, backend/domain.py: Sizzle.record, the one write path (verbatim, lines 52-56)·python
    def record(self, type_: str, data: dict[str, Any]) -> dict[str, Any]:
        with self._write:
            event = self.journal.append(type_, data)
            self.store.apply(event)
            return event
cwkSizzle, backend/journal.py: Journal.append, one line, flushed and fsynced under a lock (verbatim, lines 33-41)·python
    def append(self, type_: str, data: dict[str, Any]) -> dict[str, Any]:
        event = {"id": new_ulid(), "type": type_, "at": utc_now(), "data": data}
        line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
        with self._lock:
            with self.path.open("a", encoding="utf-8") as stream:
                stream.write(line + "\n")
                stream.flush()
                os.fsync(stream.fileno())
        return event

External links

Exercise

Take a small app of your own that writes to a database directly. Add an append-only JSONL journal and route every write through one function that appends the event, fsyncs it, and only then applies it to the database. Run the app for a while, copy the database aside, delete it, and rebuild it by replaying the journal. Compare the rebuilt database with the copy.
Hint
Start with one table and two event types so the apply step stays small. sqlite3's iterdump on both files gives you two texts you can diff. Any difference is a write that bypassed your one function.

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.