How Recovery Works

BanditDB survives crashes and restarts automatically. No manual intervention required.

The Tuple Lifecycle

Every decision moves through five stages, from predict() to a folded-in matrix update:

1 ยท predict()
tuple opens:
(ctx, arm,
 propensity)
TTL cache, keyed by interaction_id
โ†’
2 ยท your code
acts on arm,
observes
outcome
outside BanditDB
โ†’
3 ยท reward()
tuple closes:
+ reward
join by interaction_id
attribution done for you
โ†’
4 ยท WAL append
durable before
state mutates
ยท self-healing retries
crash-safe
โ†’
5 ยท matrix fold
Sherman-Morrison
O(dยฒ) in-place
ยท ยตs
policy updated

closed tuples โ†’ Parquet export ยท replayable ยท causal analysis via logged propensities (LinUCB)

The Two Files

FilePurpose
checkpoint.jsonSnapshot of all campaign matrices (Aโปยน, b, ฮธ, counts), plus the predictions still awaiting a reward.
checkpoint.prevThe previous generation, retained. If the current checkpoint is unreadable this is what recovery falls back to.
bandit_wal.jsonlAppend-only event log: CampaignCreated, Predicted, Rewarded, CampaignDeleted.

Phase 1 โ€” Load the Checkpoint

If checkpoint.json exists, BanditDB reads it and restores all campaign matrices directly into memory โ€” no replaying, just deserialisation. The checkpoint records the WAL byte offset at which it was taken.

If no checkpoint exists, BanditDB starts from an empty state and replays the entire WAL from byte 0.

If a checkpoint exists but cannot be read, BanditDB falls back to checkpoint.prev. If neither is readable it refuses to start rather than coming up empty โ€” an empty start would serve a blank database and then overwrite the evidence at the next checkpoint. Set BANDITDB_ALLOW_CORRUPT_CHECKPOINT=true to override and accept the loss.

Phase 2 โ€” Replay the WAL Tail

BanditDB opens bandit_wal.jsonl, seeks to the checkpoint's byte offset, and replays every event written after that point. A completed checkpoint ends with WAL rotation, which rewrites the log to begin exactly at the checkpoint boundary โ€” so the recorded offset is 0 and replay starts from the beginning of the rotated file.

checkpoint.json readable? โ”œโ”€โ”€ YES โ†’ restore matrices + pending predictions โ”‚ โ†’ open WAL, seek to checkpoint.wal_offset โ”‚ โ†’ replay events from that position โ”œโ”€โ”€ CORRUPT โ†’ try checkpoint.prev โ”‚ โ”œโ”€โ”€ readable โ†’ recover one generation back โ”‚ โ””โ”€โ”€ no โ†’ refuse to start โ””โ”€โ”€ ABSENT โ†’ open WAL, replay from byte 0

Durability Guarantees

Durability is split by event type, deliberately. What you get depends on what you wrote.

EventGuarantee
Rewards Acknowledged only after fsync. POST /reward does not return until the record is on disk, so a 200 response survives process death, power loss, and VM preemption alike.
Campaign lifecycle
create, delete, archive, restore
Same guarantee. A 200 from POST /campaign means the campaign survives a restart.
Predictions Best-effort. Under WAL backpressure a prediction log record is dropped rather than failing the request. Watch banditdb_wal_dropped_total.

RPO for acknowledged writes is zero. This is not bounded by the checkpoint interval โ€” checkpointing controls WAL size and replay time, not durability. Only in-flight requests that never received a response are lost, which is ordinary for any database.

Predictions are weaker on purpose, and it costs less than it sounds. A lost prediction record costs the ability to match one late reward; it never affects model state. The predictions still awaiting a reward travel inside checkpoint.json, so a reward arriving after a restart still matches the prediction that preceded it.

The cost: waiting for the fsync adds roughly 3.4 ms to a reward call at concurrency 1. The server amortises one fsync across all concurrent callers, so parallel submission reaches ~4,400 rewards/second while a strictly serial loop pays the full latency every time. Batch or thread your reward submission if throughput matters.

What POST /checkpoint Does

  1. Flush barrier โ€” drains all pending events and fsyncs to disk, responds with confirmed byte offset.
  2. Snapshot โ€” serialises all campaign matrices and the predictions still awaiting a reward to checkpoint.tmp. The tmp file is fsynced, the previous generation is retained as checkpoint.prev, then the rename is made durable by fsyncing the directory.
  3. Parquet export โ€” joins Predicted + Rewarded events, writes matched pairs as a timestamped shard per campaign. Shards older than BANDITDB_EXPORT_RETAIN_SHARDS (default 50) are pruned.
  4. Neural retrain (NeuralLinUCB campaigns only) โ€” if retrain_every rewards have accumulated since the last retrain, runs Algorithm 2, re-accumulates arm matrices in the new embedding space, and saves .safetensors weights to {DATA_DIR}/neural/.
  5. WAL rotation โ€” truncates WAL to only the tail. Pre-checkpoint history is no longer needed for recovery.

Parquet files are analytics exports only โ€” not used for recovery. Losing them does not affect model state. Recovery uses only checkpoint.json + bandit_wal.jsonl.

Recommended Production Setup

# Auto-checkpoint every 10,000 rewards
BANDITDB_CHECKPOINT_INTERVAL=10000

# Or cap WAL size (useful on edge deployments)
BANDITDB_MAX_WAL_SIZE_MB=50

# Back up the two recovery files on a schedule
cp /data/checkpoint.json  /backup/checkpoint-$(date +%s).json
cp /data/bandit_wal.jsonl /backup/wal-$(date +%s).jsonl

To move BanditDB to a new host: copy checkpoint.json and bandit_wal.jsonl to the same DATA_DIR on the new machine and start. Recovery is automatic.