~/systems/serialization

Serialization and durability

Turn data into bytes you can read back exactly, and keep a store correct across crashes with a write-ahead log that replays up to the last whole record.

what

Prefix every field with its length instead of using separators. Append each change to a log as a record with a length and a checksum before applying it, and on startup replay records until one is torn or corrupt.

use when

Saving a key-value store or cache to disk, designing a binary format, recovering after a crash, splitting data into bounded chunks, or checkpointing a long job.

time

O(bytes) to encode, decode or replay

space

O(bytes)

You’ll recognise it when

  • You must save data and get exactly the same data back, including strings that contain your separator, newlines or arbitrary bytes.
  • A store must survive a crash: the process can die mid-write, and a restart must recover everything that was acknowledged.
  • Storage has rules: files capped at a size, writes that may stop halfway, a fake file system the tests can break on purpose.
  • A long job must checkpoint and resume exactly where it stopped.

It’s often confused with “just use JSON”. JSON is a fine format, but the question is usually about what the format can’t do on its own: say where a record ends when the file was cut short, and what to trust after a crash.

The idea

A ship’s logbook is written in ink, one entry after another, and never edited. If the ship’s computer reboots, the crew rebuilds the current state by reading the log from the top. If the last entry was interrupted mid-sentence, they ignore the half-written line: everything before it is still good.

That’s a write-ahead log. Every change is appended to the log before it’s applied in memory, so anything the store ever acknowledged can be replayed. Two details make replay safe. Each record starts with its length, so the reader knows exactly where it ends, whatever bytes are inside. And each record carries a checksum of its contents, so a torn or damaged record is detected instead of misread. Replay stops at the first record that fails either test.

How it works

Build a durable key-value store. Each record is a header of two 4-byte big-endian numbers, a checksum and a length, then the payload: an op byte (S for set, D for delete), then the key and the value, each with its own 4-byte length in front.

  1. Encode a record. Build the payload with struct.pack(">I", n) before each field. No separators: a value like a|b=c, with spaces needs no escaping, because the reader never searches for a delimiter.
  2. Log first, then apply. set(key, value) appends the record to log, then updates the in-memory dict. A crash between the two loses nothing: replay redoes the change. Real code also calls flush() and os.fsync() before saying “done”.
  3. Replay from the top. Start at pos = 0. Read the header. If fewer than 8 bytes are left, or pos + 8 + length runs past the end, the record was torn mid-write: stop.
  4. Check the contents. Compute the CRC-32 of the payload. If it doesn’t match checksum, the bytes were damaged: stop, and trust nothing after it. Otherwise apply the set or delete, and move pos to the end of the record.
  5. Cut the tail. The valid part of the log ends at pos. Truncate the file there before appending anything new, or new records would sit behind garbage and never be replayed.

Four records (set name, set note, delete name, set name again) take 33, 39, 21 and 26 bytes. If the process dies 3 bytes before the end of the fourth, replay applies the first three, finds that the fourth’s length runs past the end, and stops at byte 93. The store recovers note and no name, which is exactly the state after the last complete write. Flip one byte inside the second record instead, and its checksum fails: only the first record is replayed.

Why it’s correct: a record is applied only if all of its bytes are present and match the checksum it was written with. Records are applied in the order they were written, and replay stops at the first failure, so the recovered state is always the state after some complete prefix of the writes.

import struct
import zlib
HEADER = struct.Struct(">II") # checksum, payload length: 4 bytes each, big-endian
U32 = struct.Struct(">I")
def encode_record(op, key, value=b""):
# op is b"S" (set) or b"D" (delete); every field is length-prefixed
payload = op + U32.pack(len(key)) + key + U32.pack(len(value)) + value
return HEADER.pack(zlib.crc32(payload), len(payload)) + payload
def replay(log):
"""Rebuilds the store from a log. Stops at the first torn or corrupt record."""
store, pos, records = {}, 0, 0
while pos + HEADER.size <= len(log):
checksum, length = HEADER.unpack_from(log, pos)
end = pos + HEADER.size + length
if end > len(log):
break # torn write: the record never finished
payload = bytes(log[pos + HEADER.size:end])
if zlib.crc32(payload) != checksum:
break # corrupt: trust nothing after it
(klen,) = U32.unpack_from(payload, 1)
key = payload[5:5 + klen]
(vlen,) = U32.unpack_from(payload, 5 + klen)
value = payload[9 + klen:9 + klen + vlen]
if payload[:1] == b"S":
store[key] = value
else:
store.pop(key, None)
pos, records = end, records + 1
return store, records, pos
class DurableKV:
def __init__(self, log=b""):
self.log = bytearray(log)
self.store, self.records, valid = replay(self.log)
del self.log[valid:] # cut a torn tail before appending after it
def set(self, key, value):
self.log += encode_record(b"S", key, value) # log first...
self.store[key] = value # ...then memory
def delete(self, key):
self.log += encode_record(b"D", key)
self.store.pop(key, None)
#include <cstdint>
#include <map>
#include <string>
#include <tuple>
using namespace std;
// The standard CRC-32 (the same as zlib.crc32 and java.util.zip.CRC32).
uint32_t crc32(const string& data) {
uint32_t crc = 0xFFFFFFFFu;
for (unsigned char byte : data) {
crc ^= byte;
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
return ~crc;
}
void put_u32(string& out, uint32_t x) { // 4 bytes, big-endian
for (int shift = 24; shift >= 0; shift -= 8) out += char((x >> shift) & 0xFF);
}
uint32_t get_u32(const string& s, size_t pos) {
uint32_t x = 0;
for (int i = 0; i < 4; i++) x = (x << 8) | (unsigned char)s[pos + i];
return x;
}
// op is 'S' (set) or 'D' (delete); every field is length-prefixed
string encode_record(char op, const string& key, const string& value = "") {
string payload(1, op);
put_u32(payload, key.size());
payload += key;
put_u32(payload, value.size());
payload += value;
string record;
put_u32(record, crc32(payload));
put_u32(record, payload.size());
return record + payload;
}
// Rebuilds the store from a log. Stops at the first torn or corrupt record.
// Returns {store, records replayed, bytes that were valid}.
tuple<map<string, string>, int, size_t> replay(const string& log) {
map<string, string> store;
size_t pos = 0;
int records = 0;
while (pos + 8 <= log.size()) {
uint32_t checksum = get_u32(log, pos), length = get_u32(log, pos + 4);
if (length > log.size() - pos - 8) break; // torn write: the record never finished
string payload = log.substr(pos + 8, length);
if (crc32(payload) != checksum) break; // corrupt: trust nothing after it
uint32_t klen = get_u32(payload, 1);
string key = payload.substr(5, klen);
uint32_t vlen = get_u32(payload, 5 + klen);
string value = payload.substr(9 + klen, vlen);
if (payload[0] == 'S') store[key] = value;
else store.erase(key);
pos += 8 + length;
records++;
}
return {store, records, pos};
}
class DurableKV {
public:
string log;
map<string, string> store;
int records = 0;
explicit DurableKV(const string& existing = "") {
size_t valid;
tie(store, records, valid) = replay(existing);
log = existing.substr(0, valid); // cut a torn tail before appending after it
}
void set(const string& key, const string& value) {
log += encode_record('S', key, value); // log first...
store[key] = value; // ...then memory
}
void erase(const string& key) {
log += encode_record('D', key);
store.erase(key);
}
};
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.CRC32;
class DurableKV {
ByteArrayOutputStream log = new ByteArrayOutputStream();
TreeMap<String, String> store = new TreeMap<>();
int records = 0;
static long crc(byte[] data) {
CRC32 c = new CRC32();
c.update(data);
return c.getValue();
}
// op is 'S' (set) or 'D' (delete); every field is length-prefixed
static byte[] encodeRecord(char op, String key, String value) {
byte[] k = key.getBytes(StandardCharsets.UTF_8), v = value.getBytes(StandardCharsets.UTF_8);
ByteBuffer payload = ByteBuffer.allocate(9 + k.length + v.length); // big-endian by default
payload.put((byte) op).putInt(k.length).put(k).putInt(v.length).put(v);
byte[] p = payload.array();
return ByteBuffer.allocate(8 + p.length).putInt((int) crc(p)).putInt(p.length).put(p).array();
}
// Rebuilds the store from a log. Stops at the first torn or corrupt record.
DurableKV(byte[] existing) {
ByteBuffer buf = ByteBuffer.wrap(existing);
int pos = 0;
while (pos + 8 <= existing.length) {
long checksum = buf.getInt(pos) & 0xFFFFFFFFL;
long length = buf.getInt(pos + 4) & 0xFFFFFFFFL;
if (length > existing.length - pos - 8) break; // torn write: the record never finished
byte[] p = Arrays.copyOfRange(existing, pos + 8, pos + 8 + (int) length);
if (crc(p) != checksum) break; // corrupt: trust nothing after it
ByteBuffer payload = ByteBuffer.wrap(p);
int klen = payload.getInt(1);
String key = new String(p, 5, klen, StandardCharsets.UTF_8);
int vlen = payload.getInt(5 + klen);
String value = new String(p, 9 + klen, vlen, StandardCharsets.UTF_8);
if (p[0] == 'S') store.put(key, value);
else store.remove(key);
pos += 8 + (int) length;
records++;
}
log.write(existing, 0, pos); // cut a torn tail before appending after it
}
void set(String key, String value) {
log.writeBytes(encodeRecord('S', key, value)); // log first...
store.put(key, value); // ...then memory
}
void delete(String key) {
log.writeBytes(encodeRecord('D', key, ""));
store.remove(key);
}
}

Snapshots and compaction

A log that’s never trimmed grows forever and makes startup slow. Compact it: write the current state as a fresh snapshot (one set record per live key) to a new file, fsync it, then atomically swap it in with os.replace. A crash before the swap leaves the old log; a crash after it leaves the new one. Never truncate the old log in place before the new state is safely on disk.

When text is better

When people will read the file, use escaping instead of lengths: write \n for a newline and \\ for a backslash, and when reading, decode escapes left to right in one pass. It’s readable, but every reader must agree on the escape rules, and a length prefix is simpler to get right.

Why it’s O(bytes)

Encoding writes each byte of a record once. Replay reads each byte once to find the record and once more for the checksum, so recovery is O(size of the log). That’s why compaction matters: the log’s size, not the number of keys, sets the startup time. Each set costs O(key + value) to encode and append, plus the cost of fsync, which is far slower than everything else here (often a millisecond or more on a real disk). Batching several records per fsync is the usual trade between speed and how much a crash can lose.

Common mistakes

Using a delimiter the data can contain

Joining strings with | or \n works until a value contains one. Then decoding splits it in the wrong place and every later field shifts.

data = "|".join(values) # ✗ "a|b" splits into two
data = b"".join(U32.pack(len(v)) + v for v in values) # ✓ length first

Applying the change before logging it

If memory is updated first and the process dies before the log write, the caller was told “done” for a change that recovery never sees.

self.store[key] = value; self.log += record # ✗ a crash in between loses it
self.log += record; self.store[key] = value # ✓ the log is the source of truth

Trusting a partial record

A crash can leave half a record at the end of the file. Reading its length and then whatever bytes happen to follow makes up a value that was never written.

payload = log[pos + 8:pos + 8 + length] # ✗ may be shorter than length
if pos + 8 + length > len(log): break # ✓ torn: stop here

Mixing up flush and fsync

f.flush() hands Python’s buffer to the operating system, which may keep it in memory for a while. Only os.fsync(f.fileno()) asks for it to reach the disk. Say which one your durability promise relies on.

f.write(record); f.flush() # ✗ survives a crash of the program, not of the machine
f.write(record); f.flush(); os.fsync(f.fileno()) # ✓ on disk before you acknowledge

Variations

  • Strings and bytes. Lengths count bytes, not characters: encode text with UTF-8 first. "é" is one character and two bytes.
  • Chunked files. When each file may hold at most N bytes, split the encoded snapshot into numbered chunks, write a small manifest last, and read the manifest first on restore.
  • Compact integers. Small numbers can use fewer bytes: a varint stores 7 bits per byte with a “more follows” bit. Columnar formats add run-length encoding and bit-packing on top.
  • Durable caches. Log every access of an LRU cache, replay it on startup to rebuild the order, and compact to one record per live entry when the log gets long.
  • Checkpointing iterators. Persist the smallest state that resumes exactly: a cursor, a random generator’s state, an epoch number. See iterators.

Climb the ladder

Our serialization problems in ladder order.

  1. Length-prefixed encoding of a list of strings: lengths instead of separators.
  2. Escaped key=value settings file: the readable alternative, with escapes.
  3. Encode and decode with RLE and bit-packing: compact integer columns.
  4. Memoizing LRU cache that survives crashes: a cache key, a log of every call, then compaction.
  5. Durable key-value store serialization: snapshots, size-capped chunks, crash-safe swaps and a log with recovery.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    What does this print?

    import struct
    def decode(data):
    out, pos = [], 0
    while pos < len(data):
    (n,) = struct.unpack_from(">I", data, pos)
    out.append(data[pos + 4:pos + 4 + n].decode())
    pos += 4 + n
    return out
    data = b"".join(struct.pack(">I", len(s)) + s for s in [b"a|b", b"", b"cd"])
    print(len(data), decode(data))
  2. 2

    A store’s set() must not lose a write it has acknowledged, even if the machine loses power. Which order of steps is right?

  3. 3

    A log holds three records of 30, 25 and 40 bytes, in that order. A crash cut the file at byte 80. What should recovery do?

  4. 4

    A length prefix must count what’s actually written. What does this print?

    s = "café"
    print(len(s), len(s.encode("utf-8")))
  5. 5

    A write-ahead log has grown huge, and you want to replace it with a compact snapshot of the current state. Which procedure survives a crash at any moment?

Practice problems

Solve these right here, in Python, C++ or Java. Tests run as you go.

Further reading

esc