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

A Torn Last Line Is Forgiven

~13 min · journal, crash-safety, replay, corruption

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

The one damage you should expect

Every append writes one line and a newline, flushes, and fsyncs before it returns, and the next append cannot start until that one has. So the damage the write path itself leaves has one expected place: the end of the file. If the process dies or the machine loses power in the middle of an append, the journal can end with a fragment, part of a JSON object with no closing brace and no newline.

That expectation rests on an assumption. Lesson 1 showed that a plain fsync on macOS hands the bytes to the drive without promising when, or in what order, the drive stores them. A power cut that catches the drive's cache can in principle leave damage further back too. The engine does not try to tell that apart from any other corruption, and neither does this lesson.

That event never happened. append never returned, so record never applied it, the route never answered, and no client was told the plate had landed. A phone that sent it still holds the plate in its outbox and sends it again later with the same request id (lesson 4 is why that is safe). Dropping the fragment loses nothing anyone was promised.

How replay tells the two kinds apart

Journal.events reads the whole file, splits it into lines, and first finds last, the index of the last line that is not blank. Then it walks the lines in order. Blank lines are skipped. Every other line goes through json.loads. When one fails, the function asks a single question: is this the last non-blank line?

What replay meetsWhereWhat happens
A line that does not parseThe last non-blank lineA warning names the line, the generator ends, and the rebuild uses everything before it
A line that does not parseAnywhere earlierJournalError with the file and line number, and the rebuild stops
A fragment cut inside a multi-byte characterThe end of the fileUnicodeDecodeError from read_text, before any line is parsed, and the rebuild stops
A blank lineAnywhereSkipped
Valid JSON with an unknown typeAnywhereValueError from the store, and the rebuild stops (lesson 3)

When the rebuild stops, it stops inside the engine's startup, so the engine does not come up at all. That is loud on purpose.

Why stopping is the right answer

Damage in the middle of the journal is not what an interrupted append leaves, because every earlier line was fsynced before a later one began. It means something else went wrong: a failing disk, a drive cache that lost writes it had accepted, a hand edit, a bad copy. That deserves a person, and the error message tells the person exactly where to look.

Skipping the line instead would build a mirror that is quietly wrong. Suppose the lost line was a class.proposed. A later class.adopted for that class would update zero rows without complaint, and a later class.linked would tie a plate to a class that does not exist. Every screen would still render. Nothing would say the record had a hole in it. A rebuild that refuses to start is easier to live with than one that starts with a hole.

Principle: Forgive only the damage your own write path can produce. Anything else is evidence, and evidence should stop the line.

Where the forgiveness ends

Here is something I found while writing this lesson. The forgiveness lives only on the read side. events skips the fragment, but nothing ever removes it from the file. The engine starts, and the next append opens the file in append mode and writes its line straight after the fragment, on the same line, because the fragment never got its newline.

I ran that against the real backend/journal.py in a scratch directory, nowhere near Dad's data. One good event, then a fragment, then one more append. Replay returned only the first event. The new one had fused with the fragment into one unparseable line, and since that line was now last, replay forgave it too. One append later, the fused line was no longer last, and replay raised JournalError for line 2.

So the expected damage, followed by ordinary use, turns into the unexpected kind within two writes. Worse, the event lost on the way was a real one: append had fsynced it and returned, and the running engine had shown it, because apply put it in the mirror. The loss only surfaces at the next start. Nothing in the engine's test suite exercises the torn-line path, so nothing caught this.

A second gap sits in front of the handler. events first decodes the whole file with read_text(encoding="utf-8"), and only then looks at lines. The journal writes Korean as it is, because of ensure_ascii=False, and a Hangul syllable takes three bytes in UTF-8. A tear that falls inside one of those leaves an incomplete character at the end of the file, and read_text raises UnicodeDecodeError before the loop and its JSONDecodeError handler ever run. I checked that too, in the same scratch setup: one good event, then a fragment that stops one byte short of a Korean syllable. The case the engine means to forgive then stops startup exactly like corruption, with an error that names a byte offset and no line.

The repair belongs at open time, before the first append, and it works on bytes, not decoded text, so a tear inside a character is just another fragment. If the file does not end with a newline, look at the bytes after the last newline. If they decode and parse as one complete event, the tear fell between the closing brace and the newline, and replay already counts that event: add the missing newline and keep it. Otherwise cut the file back to the last newline and keep the cut bytes in a side file for a human to look at. Appending a newline to a real fragment is not enough. It would turn the fragment into a middle line, and the next start would refuse it.

What to take from it

The policy in events is right, and the comment in the code says why in two sentences. What the probe shows is that a tolerance on read needs a matching repair on write, or the tolerated state does not stay the tolerated state. When you build a log like this, test the case after the crash, the first write after the restart, and not only the crash itself.

Code

cwkSizzle, backend/journal.py: Journal.events, a torn final line ignored, anything earlier fatal (verbatim, lines 43-60)·python
    def events(self) -> Iterator[dict[str, Any]]:
        if not self.path.exists():
            return
        lines = self.path.read_text(encoding="utf-8").splitlines()
        last = max((i for i, line in enumerate(lines) if line.strip()), default=-1)
        for index, line in enumerate(lines):
            if not line.strip():
                continue
            try:
                yield json.loads(line)
            except json.JSONDecodeError as exc:
                # A torn final line is the one expected damage (power lost
                # mid-append): its event never happened. Anything earlier is
                # corruption, and replay must stop rather than skip it.
                if index == last:
                    logger.warning("journal: ignoring torn final line %d", index + 1)
                    return
                raise JournalError(f"{self.path}:{index + 1}: unreadable event") from exc
Illustrative probe, hand-written for this lesson: what the next append does to a torn tail (run against backend/journal.py in a scratch directory; the comments show its output)·python
# Illustrative probe, not part of cwkSizzle: run against backend/journal.py in a scratch directory.
from pathlib import Path

from backend.journal import Journal, JournalError

path = Path("/tmp/probe/journal.jsonl")
journal = Journal(path)
journal.append("encounter.revised", {"id": "A"})
with path.open("a", encoding="utf-8") as stream:  # power lost mid-append: no brace, no newline
    stream.write('{"id":"X","type":"enc')

journal.append("encounter.revised", {"id": "C"})
print([e["data"]["id"] for e in Journal(path).events()])  # ['A']: C fused with the fragment

journal.append("encounter.revised", {"id": "D"})
try:
    list(Journal(path).events())
except JournalError as exc:
    print(exc)  # /tmp/probe/journal.jsonl:2: unreadable event

External links

Exercise

Write the repair for an append-only JSONL log of your own. On open, if the file does not end with a newline, check the bytes after the last newline. If they parse as one complete event, add the newline. Otherwise truncate the file back to the last newline and save the cut bytes to a side file. Then write five tests: a torn tail followed by two appends must replay every appended event; a tail torn inside a multi-byte character must be repaired the same way; a complete last event with only its newline missing must be kept; damage in the middle must still raise; a clean file must be left byte-for-byte unchanged.
Hint
Open the file in binary, seek to the end, and read backwards in small blocks until you find a newline byte. Decode and parse the tail yourself, and treat UnicodeDecodeError the same as JSONDecodeError. os.truncate takes the offset just after the newline. Do the repair once, before the first append, under the same lock the appends use.

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.