Browse the documentation

Durability & recovery

When a write is acknowledged, indeterminate outcomes, and recovery.

When a write is acknowledged

With the default DurabilityMode.Synchronous, a write returns only after its durable commit to the write-ahead log, so a crash cannot lose an acknowledged write. Asynchronous acknowledges earlier, trading that guarantee for latency. Concurrent writers share one disk flush (group commit); a single sequential writer pays one flush per write, so bulk loads should use AddRange or AddBatch rather than a loop of single adds.

builder.Services.AddNetlyDB(o =>
{
    o.Durability = DurabilityMode.Synchronous;  // acknowledge after the durable commit (default)
    o.CommitTimeout = TimeSpan.FromSeconds(30); // how long a caller waits for it

    o.SnapshotRetention.PreviousGenerations = 1;      // superseded generations to keep
    o.DataClasses.Default = DataClass.Critical;       // how replaceable this store's data is
});
The path of a write through NetlyDB A write is appended to a checksummed write-ahead log and only then confirmed to your code. Snapshots are published later as new generations. After a crash, recovery replays the log over the last snapshot. Your write Add, update, delete Write-ahead log checksummed, flushed to disk Confirmed returned to your code Snapshot a new generation, later 1 · logged before anything else 2 · confirmed means saved 3 · never overwritten in place After a crash: recovery replays the log over the last snapshot, the same way every time Ambiguity recovery cannot resolve stops writable startup and leaves a report.
The path of a write with synchronous durability: logged and flushed first, confirmed second, snapshotted later. After a crash, recovery replays the log over the last snapshot.

When an outcome is indeterminate

A write that outlives CommitTimeout is indeterminate, which is not the same as failed: the caller stopped waiting, the mutation did not stop. Retrying is the one thing not to do, because it may already have taken effect. Resolve it by id instead - the status survives a restart, and ResolutionProcedure says what to do in one sentence.

using NetlyDB.Core.Persistence;

var users = host.Services.GetRequiredService<IDocumentCollection<User>>();

try
{
    users.Add(new User { Name = "Alice" });
}
catch (NetlyDBIndeterminateCommitException ex)
{
    // Do not retry: the mutation may already have taken effect. Resolve it instead.
    MutationStatusResult status = await users.GetMutationStatusAsync(ex.MutationId);

    if (status.IsSettled)
    {
        // status.State, status.Status!.Lsn, status.Status.DocumentApplied
    }
    else
    {
        // status.OutcomeCode         -> catalogue code, for a log line or a support ticket
        // status.ResolutionProcedure -> what to do about this answer, in one sentence
        // status.History             -> how far back the store can still answer at all
    }
}
How a write becomes indeterminate, and how it is resolved later your code engine write submitted commit still pending CommitTimeout elapses later Add(...) Indeterminate not failed: the caller stopped waiting GetMutationStatusAsync(id) the mutation did not stop; it may still commit retrying is the one thing not to do: it may already have taken effect applied, not found within the retained history, or outside it; survives a restart
An indeterminate outcome: the caller stopped waiting, the mutation did not. Resolve it by id instead of retrying.

"Not found" inside the retained history window is a real answer. Outside it, the result says so through IsOutsideRetainedHistory rather than pretending the write never existed.

Recovery at startup

Startup classifies what it finds on disk and replays each collection's log over its last published snapshot generation. Ambiguity it cannot resolve stops writable startup rather than being skipped, and every session leaves a recovery report - readable from code, in the Management UI, and with the offline CLI. How aggressively an unreadable artifact may be discarded and rebuilt is set per collection by data classes.

From 1.2.0, recovery is stricter in two situations where earlier versions opened the store:

  • An older snapshot is used only if the log can carry it forward. If a collection's newest snapshot generation cannot be used, recovery falls back to an older one only when the retained log still holds every write between that generation and the state the store last published. Otherwise the collection is refused, and the failure names each generation and why it could not be used. Before 1.2.0 such a collection could open without writes that had been acknowledged.
  • A collection recorded as dropped, but still present, stops recovery. This happens when a process stops part-way through a drop. Nothing is deleted or recreated: clear the recorded drop, restart, and then keep the collection or drop it again.

When recovery stops, the engine is in RecoveryRequired. From 1.2.0 it refuses ordinary document and file reads as well as writes in that state, and creates no collections: data that recovery could not verify is not served. Collection names, the recovery session and report, and the health probes stay available.

Things to know

  • No transactions across collections, and no cross-collection point-in-time recovery.
  • Batches are recoverable but not atomically visible to concurrent readers.
  • Corruption in the middle of a log - between records that were complete - needs manual, offline intervention. The engine reports it plainly rather than truncating acknowledged writes.
  • Downgrade is not supported: rolling back to an earlier version means restoring a backup.
An unhandled error has occurred. Reload X

Reconnecting

Restoring the connection to the server...

Still trying

Next attempt in seconds

Connection lost

We could not reach the server.