Component · L4
WAL + Recovery
github.com/juanmmm21/write-ahead-log-recovery
Binary write-ahead log with checkpointing and redo/undo crash recovery for pluggable storage engines.
- Lines
- 1,301
- Files
- 15
- Test files
- 5
- Commits
- 15
- Started
- 8 Jul 2026
- Last commit
- 11 Jul 2026
- Python 100%
● Where it sits
Layer 4 of Strata — storage & persistence.
L1 Distribution & HA
L2 Query engine
L3 Transactions
L4 Storage & persistence
↳ Flagship
README · 5 min read
Commits per day
Component 1 of 3 in Storage & Persistence of the strata-database-engine ecosystem.
Repo: github.com/juanmmm21/write-ahead-log-recovery
A binary write-ahead log (WAL), written from scratch in Python, with periodic checkpointing and redo/undo recovery after a process crash. It is the durability foundation the ecosystem's two storage engines depend on (bplus-tree-storage-engine and lsm-tree-engine), and the replication unit that raft-replication-log will use.
What it is and what problem it solves
No database engine can guarantee that a committed write survives a process crash unless it first writes a description of that change to a sequential log and forces it to disk (fsync) before answering "committed" to the client. That is the write-ahead logging rule: no page or row is considered durable until its corresponding record is in the WAL and has gone through fsync.
This project implements that piece in isolation and in a verifiable way: a binary record format with a checksum, a writer that assigns monotonic LSNs (log sequence numbers) and syncs every write to disk, and a recovery procedure that rebuilds the correct state of an external storage engine after a crash — whether it happened mid-write (truncation) or as corruption of bytes already written.
Role in strata-database-engine
┌────────────────────────────┐
│ write-ahead-log-recovery │ (this repo)
│ LSN · fsync · redo/undo │
└──────────────┬───────────────┘
│ implements StorageApplier
┌──────────────┼───────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ bplus-tree-storage-engine │ │ lsm-tree-engine │
└──────────────────────────┘ └──────────────────────────┘
│ │
└──────────────┬───────────────┘
▼
mvcc-transaction-manager /
lock-manager-deadlock-detector
│
▼
nanosql
This repo does not import or depend on any other subproject in the ecosystem: it exposes its binary format and the StorageApplier Protocol as its integration contract. The actual integration (a storage engine replaying WAL records to rebuild its state) happens inside nanosql.
Goal / skills demonstrated
- Designing a versioned binary record format with an integrity checksum and variable-length fields.
- The WAL-before-data durability invariant and explicit
fsync. - ARIES-style redo/undo recovery (analysis, full history redo, undo of losing transactions) without physical pages.
- Checkpointing with computation of the safe truncation LSN and atomic file compaction (
os.replace). - Explicit handling of I/O failures (disk full, permissions, mid-write truncation) with dedicated typed exceptions, never generic ones.
- Property-based tests with fixed-seed random sequences, plus dedicated tests simulating a crash (truncation) and corruption (invalid checksum).
How it works
Record format
All integers are encoded big-endian:
+----------+---------+------+-----------------+----------------+------------+----------+
| MAGIC(4) | LSN(8) | type | transaction_id | payload_len(4) | payload(N) | crc32(4) |
| "WLR1" | | (1) | (8) | | | |
+----------+---------+------+-----------------+----------------+------------+----------+
The payload encodes, with an explicit length per field: the table (str), the key (bytes), the "after" value (new_value, optional), the "before" value (old_value, optional), and — only in checkpoint records — the list of active transaction ids. The CRC32 checksum covers the full header + payload, so any corrupted bit in any field is detected.
Record types (RecordType): INSERT, UPDATE, DELETE, COMMIT, ABORT, CHECKPOINT_BEGIN, CHECKPOINT_END.
Durable writes
Every call to append_insert / append_update / append_delete / commit / abort atomically assigns the next LSN, serializes the record, writes the bytes, and calls flush() and os.fsync() before returning the LSN to the caller — the record is not considered committed until that point.
Recovery (ARIES-style redo/undo)
- Analysis: the full log is scanned, noting which transactions ended up with a
COMMITrecord. - Redo: all operations in the history are reapplied in LSN order — not just those from committed transactions. This rebuilds the exact state storage had right before the crash (which already reflected writes from transactions that were not yet committed), a necessary condition for each operation's
old_valueto be valid in the next step. Limiting redo to committed transactions breaks reconstruction whenever an uncommitted transaction and a later committed one touch the same key. - Undo: transactions without a
COMMIT(explicitly aborted, or interrupted by the crash) are rolled back in reverse LSN order using each of their operations'old_value, on top of the state already reconstructed in the previous step.
A truncated tail (TruncatedRecordError, a half-written record) or real corruption (ChecksumMismatchError / InvalidRecordError) stop reading at that exact point; everything before it — already confirmed with fsync — is still recovered. The process never crashes because of this: the issue is reflected in the RecoveryReport's stopped_due_to_truncation / stopped_due_to_corruption flags.
Checkpointing and safe truncation
checkpoint(active_transaction_ids) writes a CHECKPOINT_BEGIN/CHECKPOINT_END pair and computes safe_truncation_lsn: the minimum between the checkpoint itself and the LSN of the first record of each transaction still active. truncate_before(safe_lsn) rewrites the file keeping only records with lsn >= safe_lsn, writing to a temporary file and swapping it in atomically (os.replace) — a crash mid-compaction leaves the original WAL untouched.
Architecture
src/write_ahead_log_recovery/
├── __init__.py # re-exported public API
├── models.py # RecordType, LogRecord, CheckpointInfo, RecoveryReport, exceptions
├── protocols.py # StorageApplier: the contract storage engines implement
├── pipeline.py # binary (de)serialization, WriteAheadLog, recover()
└── __main__.py # demonstration CLI
models.py— immutable data types (dataclass(frozen=True, slots=True)) and the exception hierarchy (WalError→WalIOError/LogCorruptionError→TruncatedRecordError/ChecksumMismatchError/InvalidRecordError).protocols.py—StorageApplier, the single coupling point with a real storage engine.pipeline.py— all the logic: binary encoding/decoding,WriteAheadLog(writer withfsync, checkpoint and truncation) andrecover()(a pure recovery function).__main__.py— a demonstration CLI with subcommands, plus an in-memoryStorageApplier(InMemoryApplier) to illustraterecoverwithout depending on any real engine.
Concurrency: WriteAheadLog guards the next LSN, the next transaction id, and the file's write position with a single threading.Lock — the design assumes a single logical writer per WAL file (several threads may call append_* concurrently, but serialization to disk is always sequential).
Requirements and installation
- Python
>=3.11
git clone https://github.com/juanmmm21/write-ahead-log-recovery.git
cd write-ahead-log-recovery
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # or: pip install -e . pytest mypy ruff
Usage
CLI
# Start a transaction, insert, commit
txn=$(python -m write_ahead_log_recovery begin --path /tmp/demo.wal)
python -m write_ahead_log_recovery insert --path /tmp/demo.wal --txn "$txn" \
--table users --key alice --value v1
python -m write_ahead_log_recovery commit --path /tmp/demo.wal --txn "$txn"
# Inspect the log
python -m write_ahead_log_recovery dump --path /tmp/demo.wal
# Recover (redo/undo) into a demo in-memory StorageApplier
python -m write_ahead_log_recovery recover --path /tmp/demo.wal
# Checkpoint + truncate
python -m write_ahead_log_recovery checkpoint --path /tmp/demo.wal --active "" --truncate
Programmatic usage
from pathlib import Path
from write_ahead_log_recovery import WriteAheadLog, recover
class DictApplier:
def __init__(self) -> None:
self.data: dict[bytes, bytes] = {}
def apply_insert(self, table: str, key: bytes, value: bytes) -> None:
self.data[key] = value
def apply_update(self, table: str, key: bytes, value: bytes) -> None:
self.data[key] = value
def apply_delete(self, table: str, key: bytes) -> None:
self.data.pop(key, None)
wal_path = Path("demo.wal")
with WriteAheadLog(wal_path) as wal:
txn = wal.begin_transaction()
wal.append_insert(txn, "users", b"alice", b"v1")
wal.commit(txn)
applier = DictApplier()
report = recover(wal_path, applier)
print(applier.data, report)
Data format / interface exposed to nanosql
Any storage engine that wants to replay this WAL's effects implements the StorageApplier Protocol (apply_insert, apply_update, apply_delete) and passes it to recover(path, applier). The file's binary format (MAGIC = b"WLR1") is versioned in every record's header so it can evolve without breaking existing logs.
Development
pytest
ruff check .
ruff format --check .
mypy --strict src/
The test suite covers: binary serialization round-trips for all 7 record types, truncation detection at different cut points, checksum corruption detection without interrupting the process, basic redo/undo, checkpoint + safe truncation, and property-based tests with fixed-seed random workloads that compare the recovered state against a reference oracle.
Benchmarks
Not applicable at this stage: this subproject's goal is the correctness of the durability invariant and of recovery, not performance. bplus-tree-storage-engine and lsm-tree-engine, which do have real performance pressure, will include their own benchmarks.
Troubleshooting
ChecksumMismatchErrorwhen reading an existing WAL: there is real corruption of bytes already written (not a truncation).recover()does not raise this exception — it is reflected inRecoveryReport.stopped_due_to_corruption, and reading stops at that point while keeping everything read before it. If the exact detail is needed,iter_recordscan be called directly, since it does propagate it.WalIOErroronappend_*ortruncate_before: a real I/O failure (disk full, permissions). The message includes the path and the originalOSError.- The WAL doesn't grow after several
checkpointcalls:checkpoint()alone doesn't truncate anything — it only computessafe_truncation_lsn.truncate_before(info.safe_truncation_lsn)must be called explicitly (or use--truncateon the CLI).
License
MIT — see LICENSE.