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
- 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
Write the new WAL head into the inactive header page
- 3now durable
fsync()the file, once for both - 4now visible
Update the in-memory delta
- 5
Return success to the caller
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:
header, 20 bytes
body
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:
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 segments, elsewhere in the file
- 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:
- 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
fsync()the extentIn every sync mode, before any header names it
- 3spilled
Install a header naming the segment and an empty WAL, durably in both header slots
- 4
Start the WAL over under a fresh salt
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.
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.
- 1commit lock
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
- 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
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
Replay the transactions committed after the cut over the new snapshot
Most without the commit lock; the last ones under it, in step 5
- 5commit lock
Install a header naming the new snapshot with
covered = C, durably in both header slotsIt 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.
Durability guarantees
Durability is configurable with the syncMode option:
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
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
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
Fullmode) one fsync. This is always on;groupCommitEnabledhas 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
walSizemeans 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)
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:
- 1
Read both header pages and use the newest valid one to find the snapshot, the WAL and the WAL segments
- 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
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
Scan the WAL's records up to its head
- 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
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
Group records by transaction
A transaction with Begin but no Commit, or with a Rollback, is discarded
- 8
Replay, in commit order, the transactions whose Commit record lies after the last record of segment
coveredEarlier 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.
When checkpoints happen
Automatic:
- 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 mostcheckpointLogBudget(default 128 MiB). Segments kept for a still-open transaction don't count - 2When the WAL segments reach
walSegmentLimitand a writer needs to spill - 3On
Kiteclose, when the uncovered log is at leastcloseCheckpointIfWalUsageAtLeast(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):
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 threadcheckpointError() 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 itA 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 raisewalSegmentLimit(andwalSegmentSizewith 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,vacuumorresizeWalis 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
- Single-file format: how the WAL and its segments fit in the file layout
- Snapshot and delta: what a checkpoint produces
- MVCC and transactions: how transactions work