Table of Contents

Write-Ahead Log (WAL v2 logical records)

The single source of durability truth: logical (EntityId, slot) records, one codec, a sequential CRC-chained log.

Status: โœ… Implemented ยท Visibility: Public ยท Level: ๐ŸŸฃ Advanced ยท Category: Durability

๐ŸŽฏ What it solves

A crash must never lose a write the application was told succeeded, and recovery must never have to guess what physically changed on disk. Logging entire data pages for a small component update wastes most of the I/O and ties the log to wherever the data happened to live โ€” a relocation or compaction breaks replay. Typhon's WAL records only the logical fact โ€” this entity's component now has these bytes โ€” so log volume tracks the size of your changes, not your page size, and replay survives a different on-disk layout than the one that produced the log.

โš™๏ธ How it works (in brief)

Every commit assembles its changes โ€” spawns, component upserts, collection edits, destroys โ€” into one batch and hands it to a single codec, which serializes it as logical records into a sequential per-database log. Records are framed in CRC-chained chunks, so a torn last write is detected and the log truncates cleanly at the first invalid chunk rather than being misread. Producer threads (your transactions) claim space in a lock-free buffer; a dedicated writer thread drains it and flushes to disk, optionally with Force-Unit-Access so a confirmed write is physically durable, not just OS-buffered. No record ever names a page, chunk, or buffer โ€” only (EntityId, slot), the per-archetype component slot resolved under the EntityId's routing id โ€” so the physical placement is re-derived at apply time.

๐Ÿ’ป Usage

The WAL is always on and runs transparently under every commit โ€” you configure it once at engine setup and otherwise never touch it directly:

services
    .AddScopedManagedPagedMemoryMappedFile(o =>
    {
        o.DatabaseName = "skirmish";
        o.DatabaseDirectory = ".";
    })
    .AddScopedDatabaseEngine(o =>
    {
        o.Wal = new WalWriterOptions
        {
            WalDirectory = "wal",
            SegmentSize = 64 * 1024 * 1024,    // 64 MB segments
            GroupCommitIntervalMs = 5,
        };
    });

// Every Commit() builds a logical record batch and appends it โ€” no separate WAL API to call.
using var uow = dbe.CreateUnitOfWork(DurabilityMode.GroupCommit);
using var tx = uow.CreateTransaction();
var e = tx.OpenMut(soldier);
e.Write(Unit.Health).Current -= 25;
tx.Commit();   // batch appended now; durable on the next GroupCommit flush
Option Default Effect
WalDirectory "wal" Directory holding WAL segment files
SegmentSize 64 MB Size of each pre-allocated segment file
PreAllocateSegments 4 Segments kept pre-allocated ahead of the write position
GroupCommitIntervalMs 5 Auto-flush interval consumed by DurabilityMode.GroupCommit
UseFUA true Per-write Force-Unit-Access durability vs. relying on explicit flush
StagingBufferSize 256 KB Aligned staging buffer size used for direct I/O writes
WriterThreadCoreAffinity -1 (none) Pin the WAL writer thread to a logical core

โš ๏ธ Guarantees & limits

  • Mandatory, not optional โ€” every DatabaseEngine runs the WAL; it cannot be turned off (only its disk backend can be swapped for an in-process one in tests/benchmarks).
  • Logical-only records โ€” never a page, chunk, or buffer ID โ€” so replay tolerates a different allocation outcome than the run that produced the log.
  • One codec, one format โ€” all record bytes are written and read by a single codec on both the commit and recovery paths; no second, divergent path can drift out of sync.
  • Torn-tail safe โ€” chunks are CRC-chained; a partial last write is detected and the log truncates at the first invalid chunk instead of being misread as valid data.
  • Honest watermarks โ€” CheckpointLSN โ‰ค DurableLsn โ‰ค LastAppendedLsn always holds; DurableLsn never claims a record durable before it is actually fsynced.
  • Bounded record size โ€” a single record (header + payload) is capped at chunk size minus envelope (~64 KB); oversized component/collection payloads are rejected at schema registration, not at WAL-write time.
  • Commit stays cheap โ€” no disk I/O on the commit path itself beyond serializing into the in-memory buffer (~1โ€“2 ยตs); FUA cost (~10โ€“80 ยตs) is paid only when a mode actually waits for it.
  • Backpressure, not silent loss โ€” a full commit buffer surfaces as a transient WalBackPressureTimeoutException; a single claim larger than the buffer throws WalClaimTooLargeException. Append either appends every record or throws โ€” never a partial write.
  • Not a torn-page repair tool โ€” the WAL records logical changes, not page images; recovering a torn data page is the job of checkpoint A/B pairing and structure rebuild (see the Checkpoint and Crash Recovery entries), not the log itself.

๐Ÿงช Tests

  • WalCommitBufferTests โ€” producer-side lock-free claim/publish/drain/overflow semantics
  • WalWriterTests โ€” writer-thread drain/flush pipeline, FUA vs. buffered durability
  • WalRecordHeaderTests โ€” on-disk logical record/frame/chunk layout (the (EntityId, slot) format itself)
  • WalIntegrationTests โ€” end-to-end WAL pipeline across all three DurabilityModes with real disk I/O