Skip to content

Internals

WAL and durability

Crash recovery and write-ahead logging

The write-ahead log (WAL) makes committed transactions survive crashes. Before a transaction counts as committed, its changes must be written to the WAL and flushed to disk.

The WAL principle

Log first, then applyFull sync mode (default)
  1. 1

    Write the transaction's records and a Commit record to the WAL

    If the WAL is full, it first spills into a WAL segment. Where an older record of this WAL cycle may lie, records go over bytes zeroed and synced first (see below)

  2. 2

    Write the new WAL head into the inactive header page

  3. 3

    fsync() the file, once for both

    now durable
  4. 4

    Update the in-memory delta

    now visible
  5. 5

    Return success to the caller

crash after step 3The WAL is replayed on restart and the changes are recovered.
crash before step 3The changes can be lost. That is allowed: the caller never received a success result.

Commits that arrive together share steps 1 to 3. A crash during the fsync can leave the new header on disk without some of the WAL pages it points at. Those pages then still hold what was there before, and KiteDB keeps that harmless. Records of an earlier WAL cycle fail their salt check. Where records of the current cycle may lie past the head (after reopening a file, until the WAL next starts over under a new salt at a spill or a checkpoint), records are only written over bytes zeroed and synced first, a chunk ahead of the head; in Full mode the chunk is topped up in the commits' own write, so it costs no extra fsync. Recovery reads zeros there and stops, keeping every commit acknowledged before, and never replays a record a torn write left behind.

WAL record format

Each operation is stored as a framed record:

Record layout8-byte aligned

header, 20 bytes

Length
4 B
Type
1 B
Flags
1 B
Reserved
2 B
TxID
8 B
Payload length
4 B

body

Payload
variable
CRC-32
4 B
Padding
0–7 B

The CRC-32 covers everything from Type through the end of the payload, and is XORed with the salt of the WAL region the record is in. The WAL gets a new salt whenever it starts over (after a spill, or a checkpoint that empties it), so records an earlier cycle left behind fail the check. WAL segments hold the same records unsalted: the header names each segment's byte length, so nothing past it is read. Padding brings each record to an 8-byte boundary.

Record types include:

BeginCommitRollbackCreateNodeDeleteNodeAddEdgeDeleteEdgeSetNodePropDelNodeProp

plus batch, label, schema, edge-property, and vector records.

Linear buffer

The WAL area has a fixed size. Records are appended to it until it is full; then they move into a WAL segment, an extent of pages elsewhere in the file, and the WAL starts over:

WAL area and WAL segments4 MB WAL (default)
primary region, 75%secondary, 25%
records
free
not written
head

WAL segments, elsewhere in the file

seq 5
sealed
seq 6
open
free
head
Where the next record is written
segment
An extent of pages the header names, holding records the WAL spilled; read up to the byte length the header names

Records never wrap around. The log is the WAL segments in seq order, then the WAL. When a commit does not fit in the WAL, the WAL spills into a segment and starts over, and checkpoints free the segments they cover. Every record is written to the primary region. The secondary region is only read, when opening a file from an earlier version, whose background checkpoints wrote there.

Spilling into WAL segments

When the WAL fills, the database does not force a checkpoint. It spills the WAL into a WAL segment:

Spilling a full WALmilliseconds
  1. 1

    Copy the WAL's records, unsalted, into a WAL segment

    Appended to the open extent if they fit; otherwise into a new extent, in the first free range that holds it, else at the end of the file

  2. 2

    fsync() the extent

    In every sync mode, before any header names it

  3. 3

    Install a header naming the segment and an empty WAL, durably in both header slots

    spilled
  4. 4

    Start the WAL over under a fresh salt

crash before step 3The old header still names the records in the WAL, and no bytes of the segment past the length it names. No header names a new extent's pages, and the next open frees them.
crash after step 3The header names the segment, whose bytes were synced first. A record that fails its CRC inside a named byte range is corruption, not a torn tail.

The writer whose commit needs the room does the spill: a copy of the WAL and three syncs. The WAL is not written again until both header slots name the new state, so whichever slot a crash leaves newest, the records it names are intact. A commit too large for an empty WAL, such as a large bulk load, goes straight to a WAL segment.

The header names up to 64 segments. Each entry holds the segment's seq, start page, page count, byte length, and whether it is sealed (no spill appends to it any more). The table also holds covered, the newest segment seq the snapshot covers, and the seq the next segment gets. A header that names WAL segments is written as format version 3, with minimum reader version 3, so a build that reads version 2 but not segments refuses the file with a version mismatch instead of missing the commits in its segments. Once a checkpoint covers every segment, as a clean close normally does, the header is written as version 2 again. Every header this version writes carries the magic KiteDB format 2\0: releases up to v0.2.18, which check a header only by its magic and checksum, read only its first slot and check no version, refuse these files (invalid magic number) instead of misreading them. This version opens the files of v0.2.3 to v0.2.18, and those earlier unreleased builds wrote in the old magic (a writable open rewrites both slots in the new one as its last step), except a header in the old magic that names WAL segments: its checksum, unlike the new one's, cannot tell a slot torn inside the segment table from a whole one.

v0.2.18 and earlier: the WAL never spilled. Automatic checkpoints ran on the committing thread whenever the active WAL region reached checkpointThreshold, and a commit too large for the WAL failed with WAL buffer full.

Background checkpoints

A background checkpoint folds the log into a new snapshot while reads and writes continue. Automatic checkpoints run this way on the database's checkpoint thread. backgroundCheckpoint() runs one on the calling thread, after any running one ends, and returns after its install.

Background checkpoints
  1. 1

    Cut: spill the WAL into a WAL segment and seal the newest segment

    The new snapshot will hold every transaction committed in the segments up to this one; call its seq C. When the segment table has no entry left for the spill, the cut covers the segments alone and the WAL's records follow it, so a full table never stops checkpoints

    commit lock
  2. 2

    Replay the commits up to the cut that the installed snapshot lacks into a new delta over it

    As recovery would, without locks. The live delta is not copied.

  3. 3

    Build the new snapshot from the installed snapshot and that delta

    Written to pages no header names (the first free range that holds it, else the end of the file), then synced

  4. 4

    Replay the transactions committed after the cut over the new snapshot

    Most without the commit lock; the last ones under it, in step 5

  5. 5

    Install a header naming the new snapshot with covered = C, durably in both header slots

    It keeps the WAL as it is, and every segment from the oldest one an open write transaction has records in. Then the dropped segments' pages and the old snapshot's pages are freed, and the new snapshot and delta are swapped in.

    commit lock
Reads continue throughout, and readers keep their MVCC snapshots across the swap
Commits wait for its locks only while the cut is written (milliseconds) and while the install replays the last commits, swaps in the new snapshot and delta, and frees the old ones, a time that grows with the delta (about 0.5-1 s at 1M nodes and 10M edges). Writers outrunning the run are paced (see below). Transactions open at the cut can commit during or after the checkpoint
A failure at any step loses nothing: the cut is only a spill, and every commit stays in the log. A run that ends before its install unseals the segment its cut sealed, so it takes no table entry, unless a spill started a new segment between its cut and its failure
Closing or dropping the database abandons a run still building its snapshot (no header names its pages, which are freed then or at the next open; not a failure, so nothing records it) and lets a run already installing finish
v0.2.18 and earlier: transactions that committed while a background checkpoint was running could stay invisible to reads until the database was reopened.

Durability guarantees

Durability is configurable with the syncMode option:

Sync modessyncMode
Fulldefault

One fsync per commit, or per group of commits that arrive together

Safest; slowest writes. On macOS, fsync leaves writes in the drive's cache, so commits survive power loss only with fullFsync (F_FULLFSYNC, milliseconds per commit), as with SQLite

Normal

The WAL is written to the OS on every commit; fsync happens only at spills and checkpoints

Much faster writes. Survives application crashes; an OS crash can lose recent commits

Offtesting only

No fsync at commit, and WAL writes are not flushed at commit (spills and checkpoints still sync)

Fastest; any crash can lose data

For most applications, Full is the right choice. Use Normal when you need more write throughput and can accept losing recent commits on an OS crash. In either mode, commits that arrive together share one WAL write and one header write (and, in Full, one fsync).

Fast writes (single-file)

Recommended profile for high write throughput:

  • syncMode = Normal
  • Commit from several threads: commits that arrive while others are written are written together, with one WAL write, one header write and (in Full mode) one fsync. This is always on; groupCommitEnabled has no effect, and no commit waits for others to join
  • beginBulk() + batch APIs for ingest (with or without MVCC)
  • The default 4 MB WAL is enough for heavy ingest: a full WAL spills into a WAL segment, and checkpoints run on the checkpoint thread without holding up commits. A larger walSize means fewer spills (each is a copy of the WAL and three syncs), and raises the floors of the checkpoint trigger (3/8 of the WAL), the segment limit (16 WALs) and the segment extent (2 WALs)
Durability note: Normal mode does not fsync on every commit. An OS crash can lose recent commits, but application crashes are recovered via WAL replay.

Crash recovery

On database open, the log (the WAL segments, then the WAL) is replayed to rebuild the delta:

Recovery on open
  1. 1

    Read both header pages and use the newest valid one to find the snapshot, the WAL and the WAL segments

  2. 2

    If an earlier version left records in the secondary WAL region, move them back

    A format version 2 background checkpoint wrote there between its cut and its install. A writable open moves those records after the primary region's, or into a WAL segment if they don't fit. Read-only opens replay both regions in place without writing.

  3. 3

    Read each WAL segment, by seq, up to the byte length the header names

    Its bytes were synced before any header named them, so a record that fails its CRC there is corruption, and open fails, rather than a torn tail

  4. 4

    Scan the WAL's records up to its head

  5. 5

    Validate each WAL record's CRC-32

    An invalid record ends the scan: an incomplete write (a page that never landed holds zeros written before it, or an earlier cycle's bytes), or a record of an earlier WAL cycle (its salt differs)

  6. 6

    Move the WAL head back to the last valid record

    Writable opens save this before writing anything, so new commits never land after a torn record

  7. 7

    Group records by transaction

    A transaction with Begin but no Commit, or with a Rollback, is discarded

  8. 8

    Replay, in commit order, the transactions whose Commit record lies after the last record of segment covered

    Earlier commits are in the snapshot

Replay only rebuilds the in-memory delta, so read-only opens recover too, reading the segments and the WAL in place. A writable open also frees every page no header names, such as an extent a crash left before a header named it. Recovery time is O(log size): the WAL segments plus the WAL. Automatic checkpoints keep the uncovered log near the checkpoint trigger (at most checkpointLogBudget, 128 MiB by default) unless writers outrun them, up to walSegmentLimit.

v0.2.18 and earlier: committed transactions were replayed in arbitrary order instead of commit order, so the state after a crash could differ from the state before it. Read-only opens wrote to the file when they found an interrupted background checkpoint.

When checkpoints happen

Checkpoint triggers

Automatic:

  1. 1After a commit, when the log the snapshot does not cover (the WAL segments after covered, plus the WAL) reaches the trigger: checkpointLogRatio (default 0.5) times the snapshot's size, at least three eighths of the WAL (where earlier releases checkpointed), at most checkpointLogBudget (default 128 MiB). Segments kept for a still-open transaction don't count
  2. 2When the WAL segments reach walSegmentLimit and a writer needs to spill
  3. 3On Kite close, when the uncovered log is at least closeCheckpointIfWalUsageAtLeast (default 0.2) of the trigger. This one is blocking

Manual: db.checkpoint() (blocking) or db.backgroundCheckpoint(). db.shouldCheckpoint(threshold) reports whether the uncovered log has reached that fraction of the trigger (1.0: an automatic checkpoint is due).

Automatic checkpoints (background, the default):

Run on the database's checkpoint thread, kitedb-checkpoint, started at the first automatic checkpoint, so the commit that crosses the trigger returns at once. Read-only opens have no such thread; with checkpointThread: false, without background checkpoints, or on wasm32, checkpoints run on the committing thread
Reads and writes continue while one runs (see Background checkpoints above)
A failure, on the thread or inline, is logged, recorded and returned by checkpointError() until a checkpoint succeeds. The next automatic checkpoint waits out a back-off that starts at 1 s and doubles up to 60 s while failures go on; any checkpoint that succeeds, such as the caller's own, ends it

A blocking checkpoint, such as db.checkpoint(), builds from the live delta, makes new transactions wait for its whole run and lets open ones finish first. It installs a header with an empty WAL and no WAL segments (format version 2 again); optimize, vacuum and resizeWal also leave no segments. If installing its header fails, it returns an error and the database keeps the previous snapshot and log, so later commits append after the existing records. A clean close runs one when the header names WAL segments and no transaction is open, so the closed file holds none. With a transaction open, or if that checkpoint fails (close warns and goes on), the segments stay, as they do in a database dropped without closing, and the next open replays them.

The in-memory delta takes about ten times the log's bytes, so the log budget bounds memory while checkpoints keep up: the default 128 MiB of log is about 1.3 GB of delta at most at the trigger, and writers that outrun checkpoints grow the log up to walSegmentLimit. A checkpoint briefly needs about twice that, for the delta it replays from the cut plus the live delta. To bound reopen replay time or memory, lower checkpointLogBudget. To checkpoint less often on a large database, raise checkpointLogRatio, at the cost of more memory; the budget caps the trigger either way. checkpointThreshold is deprecated and has no effect.

walSegmentLimit (default: twice the trigger, at least 16 WALs, at most four times checkpointLogBudget) bounds the bytes of WAL segments, and with them disk use and the delta's memory. The segment table bounds them too, at 63 extents (the 64th is kept for a checkpoint's cut): an extent is walSegmentSize, by default a sixteenth of the limit (from two WALs to 32 MiB, or two WALs if more), so the table holds about four times a limit of up to 512 MiB, and 2 GiB beyond. Writers wait only when the segments reach either: a writer that needs to spill then asks for a checkpoint (on the checkpoint thread, a run that starts after it asked; without the thread, one of its own) and waits for its install to free segments.

Before that, writers are paced. While a background checkpoint runs and the log is past the trigger, each commit, once it is done and holds no lock, waits its share of the time the room left below walSegmentLimit must last: the run's expected remaining time (its last run's duration, or twice its elapsed time before any run finished), spread over the log's growth. All writers share one schedule, so writers that outrun the checkpoint slow down instead of stopping at the limit for the rest of the run. A commit waits at most 100 ms, and no longer than the run: its end, installed or not, and closing the database end every wait. A commit can also wait, once per run, for the install, which holds the commit lock while it replays the last commits and frees the replaced delta: a time that grows with the delta. On the 1M-node, 10M-edge benchmark database with the default 4 MB WAL, one writer of 100-node transactions waited up to 3.6-4.6 s at the limit without pacing; with it, at most 100 ms per commit for pacing, and about 0.5-1 s once per run for the install. Blocking checkpoints (backgroundCheckpoint: false) pace no one, nor do reads, rollbacks or read-only handles. Any background checkpoint run counts, an application's backgroundCheckpoint() too, as long as automatic background checkpoints are on; with blocking or no automatic checkpoints nothing is paced. The limit stays the backstop: a run that takes much longer than the last still makes writers wait there.

Pacing holds no lock of the database's, but it waits inside the commit call. In Node the call is synchronous (there is no async commit), so a paced commit blocks the event loop for up to 100 ms, and a Kite's other calls wait for its commit. In Python the GIL is released, but the handle stays in use, so close() waits for a paced commit. A replica's catch-up applies each frame as a commit, so it may wait likewise, inside whatever its caller holds. Either way it is at most 100 ms per paced commit, in place of the stops of seconds at the limit. Up to 10 ms of the time since the last paced commit counts toward the next one's wait, so commits that are spread out (one per request, say) wait little or nothing; a writer committing in a tight loop waits its share.

Avoiding WAL overflow

A full WAL does not fail a write: it spills into a WAL segment, and a commit too large for an empty WAL goes straight to one. A write fails with WAL buffer full (WalBufferFull; WalFullError in Python) only when the WAL segments reach walSegmentLimit (or fill the segment table) and waiting for a checkpoint cannot help:

  • Automatic checkpoints are off: spills continue up to the limit, then writes fail. Run checkpoint() before the limit, or raise walSegmentLimit (and walSegmentSize with it past what the table holds).
  • Background checkpoints are off: automatic checkpoints are blocking, so the writer fails, and one runs once its transaction ends, by commit or rollback (after the back-off, if the last one failed).
  • Open write transactions hold records in enough segments to fill the limit. No checkpoint can free those until the transactions finish: keep write transactions shorter, or raise walSegmentLimit.
  • A blocking checkpoint, optimize, vacuum or resizeWal is waiting for the writer's own transaction to finish.
  • The database is closing, or the checkpoint thread cannot start.
  • The checkpoint run that answered the writer (one that started after it asked) freed no segment space.

While the last automatic checkpoint failed, a writer at the limit fails instead of waiting, with Checkpoint failed, and the WAL segments are full: ... (CheckpointFailed; Python raises CheckpointError, a KiteError subclass). With the checkpoint thread it asks for another, which the thread runs after its back-off (1 s, doubling to 60 s after each failure in a row; any checkpoint that succeeds meanwhile, such as the caller's own, ends it); without it, with background checkpoints inline, writers fail during the back-off and run one themselves after it; with background checkpoints off, writers fail until a checkpoint succeeds, and the blocking one that runs once a refused writer's transaction ends (after the back-off) is one. checkpointError() (checkpoint_error() in Python) returns the checkpoint's error. A panic in a checkpoint run is reported the same way, and the thread ends. Since such a panic may strike between writes that keep memory and disk in step, the handle then refuses writes (The database refuses writes until it is reopened: ..., WritesRefused), as it does after a panic in a commit, a spill, a checkpoint, optimize, vacuum or resizeWal that its caller catches (as the Python bindings do). Reads go on, closing persists nothing, and reopening the database recovers every acknowledged commit from disk. A rollback never waits or fails for log space: its record is not needed, since recovery drops a transaction without a commit record.

Next steps