Durability Modes
Per-Unit-of-Work control over when WAL records become crash-safe — pick latency vs. data-at-risk per workload.
Status: ✅ Implemented · Visibility: Public · Level: 🟢 Start Here · Category: Durability
🎯 What it solves
Different workloads in the same process need different durability guarantees: a game tick can tolerate losing the last ~16ms of work on crash, a player trade cannot lose anything, and general server requests sit somewhere in between. A single global durability setting forces every workload onto the slowest, most conservative choice. Durability Modes let each Unit of Work (UoW) pick its own commit-latency vs. data-at-risk trade-off — without touching the rest of the engine's guarantees: atomicity and isolation stay per-transaction in every mode.
⚙️ How it works (in brief)
DurabilityMode is fixed when a UoW is created and controls only when that UoW's WAL records are flushed
(fsync'd) to stable media — never whether a transaction commits or becomes visible. All commits stay in-memory
and MVCC-visible at the same ~1-2µs regardless of mode; the mode only changes how long the WAL record sits in
the commit buffer before it's durable. The transaction is always the unit of crash atomicity: a crash never
recovers a partial transaction, only a possibly-shorter prefix of the transactions you committed.
💻 Usage
// General server workload — bounded data-at-risk, no per-tx wait
using var uow = db.CreateUnitOfWork(DurabilityMode.GroupCommit);
using var tx = uow.CreateTransaction();
UpdatePlayerState(tx, playerId);
tx.Commit(); // ~1-2µs — durable within WalWriterOptions.GroupCommitIntervalMs (default 5ms)
// Batch import — many transactions, one flush at the end
using var batch = db.CreateUnitOfWork(DurabilityMode.Deferred);
foreach (var row in rows)
{
using var tx = batch.CreateTransaction();
ImportRow(tx, row);
tx.Commit(); // ~1-2µs — volatile until Flush()
}
await batch.FlushAsync(); // one FUA for the whole batch
// Financial trade — zero data-at-risk
using var trade = db.CreateUnitOfWork(DurabilityMode.Immediate);
using var tx2 = trade.CreateTransaction();
ExecuteTrade(tx2, alice, bob, item, gold);
tx2.Commit(); // blocks ~15-85µs — durable on the data file's WAL before returning
| Mode | Commit latency | Data-at-risk window | Notes |
|---|---|---|---|
Deferred (default) |
~1-2µs | until uow.Flush() / FlushAsync() |
best for ticks, bulk imports |
GroupCommit |
~1-2µs | ≤ WalWriterOptions.GroupCommitIntervalMs (default 5ms, engine-wide via services.AddDatabaseEngine(o => o.Wal.GroupCommitIntervalMs = …)) |
best for general request handlers |
Immediate |
~15-85µs (one WAL FUA) | zero | best for trades, irreversible state |
⚠️ Guarantees & limits
- Mode is fixed for the UoW's lifetime — there is no API to change it mid-UoW; create a separate UoW for a different durability need.
- Atomicity and isolation are unaffected by mode. Only the post-crash data-loss window changes; a crash never yields a half-applied transaction.
GroupCommitIntervalMsis an engine-wide WAL writer setting (DatabaseEngineOptions.Wal), not a per-UoW property — everyGroupCommitUoW in the engine shares the same interval.- Disposing a
DeferredUoW does not flush — unflushed transactions stay volatile until something else flushes the WAL (explicitFlush()/FlushAsync(), the GroupCommit timer, or 80%-buffer back-pressure). Disposing aGroupCommitorImmediateUoW does flush. ImmediateraisesCommitDurabilityUncertainExceptionrather than rolling back if the post-append fsync wait doesn't confirm in time — the transaction is already committed and visible; this is "durability unconfirmed," never a rollback signal. See the Commit Pipeline feature.- Known gap: the
DurabilityOverrideenum (Default/Immediate, escalating a single transaction inside an otherwiseDeferred/GroupCommitUoW) is declared on the public API surface (DurabilityMode.cs) per ADR-005, but is not yet wired intoTransaction.Commit()— there is currently no single-call escalation path for a commit within an existing UoW. Today's workaround for a critical operation inside an otherwise low-durability workload: commit it through its ownDurabilityMode.ImmediateUoW (dbe.CreateQuickTransaction(DurabilityMode.Immediate)), or, from a scheduled system, a side-transaction (ctx.CreateSideTransaction(DurabilityMode.Immediate, …)). - Max durable tx/s: ~12K-65K for
Immediate(FUA round-trip bound) vs. millions forGroupCommit/Deferred(CPU-serialization bound, amortized FUA).
🧪 Tests
- UnitOfWorkTests — mode fixed per UoW,
Flush()/FlushAsync()semantics,Deferrednot flushing on dispose - WalIntegrationTests —
Deferred/GroupCommit/Immediateexercised across dirty-page and reopen scenarios
🔗 Related
- Sub-features: Committed Durability Discipline
- Sibling: Unit of Work (durability boundary) —
DurabilityModeis fixed on the UoW at creation; the UoW is the object that owns this choice