DURABILITY / BACKUP AND RESTORE

Back up a live tree. Restore a complete generation.

Keep reads and writes running while ZoneTree captures a backup. Choose a destination, restore and verify a generation, then add a schedule and retention policy.

01 / CAPTURE
Collect a source view

Include memory records and hold immutable disk files while they are copied.

02 / TRANSFER
Reuse unchanged files

Every generation is complete. Its physical disk files can be shared with earlier generations.

03 / PUBLISH
Make it restorable

Publish the catalog after its data is committed. Retention protects files still in use.

Live backup supports built-in plain and transactional trees, including Shared and AsyncShared WALs. It captures database records; restore does not need the original production WAL files.

Choose a destination

Both destinations use the same coordinator API for manual requests, schedules, and status. Retention belongs to the provider: configure KeepLastGenerations in LocalLiveBackupOptions or ObjectStorageLiveBackupOptions. Choose a backup location with the storage separation your deployment needs; a sibling directory on the same device shares that device's lifetime.

Restore and verify a generation

These examples back up a running tree, restore the exact generation just created, and verify its records. The restore directory is unique so the example can be run again. Select the API that matches your source tree.

using ZoneTree;
using ZoneTree.Backup;

using var tree = new ZoneTreeFactory<int, string>()
    .SetDataDirectory("data/catalog")
    .OpenOrCreate();
using var maintainer = tree.CreateMaintainer();
tree.Upsert(42, "Ada Lovelace");

using var backup = tree.CreateLiveBackup(new LiveBackupOptions
{
    Store = new LocalLiveBackupProvider("backups/catalog"),
    InMemoryMode = LiveBackupInMemoryMode.Snapshot
});
await backup.CreateGenerationAsync();

using var restored = await new ZoneTreeFactory<int, string>()
    .SetDataDirectory(Path.Combine("restore-checks", Guid.NewGuid().ToString("N")))
    .RestoreFromLiveBackupGeneration("backups/catalog", backup.CurrentGenerationId);

if (!restored.TryGet(42, out var value) || value != "Ada Lovelace")
    throw new InvalidOperationException("Restored records did not match.");
using ZoneTree;
using ZoneTree.Backup;

using var tree = new ZoneTreeFactory<int, string>()
    .SetDataDirectory("data/people")
    .OpenOrCreateTransactional();
using var maintainer = tree.CreateMaintainer();
tree.Execute(tx =>
{
    tx.Upsert(42, "Ada");
    tx.Upsert(43, "Grace");
});

using var backup = tree.CreateLiveBackup("backups/people");
await backup.CreateGenerationAsync();

using var restored = await new ZoneTreeFactory<int, string>()
    .SetDataDirectory(Path.Combine("restore-checks", Guid.NewGuid().ToString("N")))
    .RestoreTransactionalFromLiveBackupGeneration("backups/people", backup.CurrentGenerationId);
using var read = restored.BeginReadOnlyTransaction();

if (!read.TryGet(42, out var ada) || ada != "Ada" ||
    !read.TryGet(43, out var grace) || grace != "Grace")
    throw new InvalidOperationException("Restored transaction did not match.");

CreateGenerationAsync() creates one generation and does not require Start(). Factory restore methods return an already opened tree. Use RestoreFromLatestLiveBackup() or RestoreTransactionalFromLatestLiveBackup() to choose the latest published generation instead of supplying an ID. Each restore overload accepts a local directory, local options, or an ILiveBackupSource, including the object-storage provider.

Restore into an unused data directory and, when configured separately, an unused WAL directory. Keep WAL enabled in the target and preserve the source's data contracts: types, serializers, comparer, deletion behavior, and compatible storage options. Plain and transactional restore methods reject the wrong tree layout.

Verify the restored data, not just the operation

Check the records and application invariants that matter to your service. Restore validates file contents and framing before publishing database metadata, but only your application can verify that the chosen generation is the one you need.

Transaction visibility

A transactional backup preserves the latest published commit snapshot at capture. Active and rolled-back transactions stay invisible after restore. A durable commit still waiting to publish is excluded; an ordinary commit already published is included even if the source has not synced it yet.

Commits pause while ZoneTree captures the segment set and transaction statuses, then continue during copying. Transactional backups always freeze the memory view and require IncludeInMemoryRecords = true, regardless of InMemoryMode.

For plain trees, the example explicitly selects Snapshot. The default Live mode can observe concurrent writes during its memory pass. A frozen storage view does not make several separate application writes atomic; use transactions for that requirement.

Schedule backups and retention

This service helper creates an initial generation, requests another every 15 minutes, and keeps the newest seven generations. runApplicationAsync represents the application's lifetime, so the tree and coordinator remain alive while scheduled work runs.

static async Task RunWithBackupsAsync(
    IZoneTree<int, string> tree,
    Func<Task> runApplicationAsync)
{
    using var backup = tree.CreateLiveBackup(new LiveBackupOptions
    {
        Store = new LocalLiveBackupProvider(new LocalLiveBackupOptions
        {
            Directory = "backups/catalog",
            KeepLastGenerations = 7
        }),
        InMemoryMode = LiveBackupInMemoryMode.Snapshot,
        Schedule = LiveBackupSchedule.Every(TimeSpan.FromMinutes(15)),
        BackupAfterMerge = false
    });

    await backup.CreateGenerationAsync();
    backup.Start();
    try
    {
        await runApplicationAsync();
    }
    finally
    {
        backup.Stop();
        await backup.WaitForLiveBackupAsync();
    }
}

Use ITransactionalZoneTree<int, string> for a transactional service; the coordinator body is the same. For object storage, replace Store with an ObjectStorageLiveBackupProvider configured with KeepLastGenerations = 7. See the combined object-storage schedule and retention example.

Scheduling requests work

Start() enables configured triggers. It does not create the first generation. The example creates that generation explicitly.

Retention counts generations

Seven generations is not seven days. More frequent schedules or additional merge triggers can shorten the history covered by that count.

Choose a trigger

Schedule Requests
LiveBackupSchedule.None Manual requests and enabled merge triggers only.
LiveBackupSchedule.Every(TimeSpan.FromMinutes(15)) One request every 15 minutes.
LiveBackupSchedule.Daily(new TimeOnly(2, 0)) Daily at 02:00 UTC.
LiveBackupSchedule.Weekly(LiveBackupSchedule.On(DayOfWeek.Sunday, new TimeOnly(2, 0))) Sunday at 02:00 UTC.

BackupAfterMerge defaults to true. After Start(), successful normal merges can request generations in addition to the schedule; bottom-segment merges do not. The example disables merge triggers to use the interval alone. A timer remains useful when changes stay in memory without triggering a merge.

Keep shared files and active restores safe

Each retained generation is logically complete. You do not replay earlier backup generations to restore it, but several catalogs may reference the same physical files. Retention deletes a file only when no retained or protected generation needs it. Keep catalogs, referenced files, and record batches together when copying a backup elsewhere.

KeepLastGenerations = null retains all published generations. A positive limit keeps the newest requested count plus generations protected by active restores, so the count can temporarily exceed the limit.

Local restores hold file leases

Factory restore releases the lease automatically. A later successful backup can reclaim released generations and abandoned files.

Object restores hold persistent pins

Pins do not expire. Inspect and explicitly release an abandoned pin after its consumer stops, then run collection. Read the pin lifecycle.

If you consume ILiveBackupSource directly, use await using for the returned LiveBackupGeneration and finish reading its streams before releasing it. Factory restore manages this lifetime for you.

Poll coordinator status

ILiveBackup.GetStatus() returns a consistent in-memory snapshot for either destination and either tree type. Polling takes the coordinator's lock and performs no storage I/O or clock reads.

var status = backup.GetStatus();
var running = status.AttemptState == LiveBackupAttemptState.Running;

Console.WriteLine($"State: {status.AttemptState}");
Console.WriteLine($"Last success: {status.LastSuccessfulGenerationId}");
Console.WriteLine($"Completed at (Unix seconds): {status.LastCompletedAtUnixSeconds}");
if (status.AttemptState == LiveBackupAttemptState.Failed)
    Console.Error.WriteLine(status.LastError);

An accepted attempt becomes Running before capture or allocation. Busy, disposed, and already-cancelled requests leave its status unchanged. Success history survives a later failure or cancellation. A new coordinator starts with None, zero IDs and timestamps, and an empty error; it does not discover existing backups. Status remains readable after disposal.

A failed attempt may already have published

The provider can publish a generation and then fail during retention, or lose an acknowledgment. GetStatus() reports the coordinator's observed outcome. Inspect the provider's retained generations to resolve uncertain publication.

Status field reference
Started
Automatic operation was enabled with Start(). Manual attempts can run while false.
AttemptState
The latest accepted attempt: None, Running, Succeeded, Failed, or Cancelled.
AttemptGenerationId
Its validated ID. Zero until allocation succeeds, and reset for each accepted attempt.
LastSuccessfulGenerationId
Latest generation successfully completed by this coordinator. Also exposed as CurrentGenerationId.
LastStartedAtUnixSeconds
When the latest attempt was accepted, including time before capture and allocation.
LastFinishedAtUnixSeconds
When that attempt finished, including cleanup. Reset to zero for the next attempt.
LastCompletedAtUnixSeconds
When the latest successful attempt finished. Preserved across failures and cancellation.
LastError
The latest failure message, including the underlying cause when wrapped. Empty initially, while running, after success, and on cancellation.

Timestamps are numeric Unix seconds; zero means unset. They represent wall-clock instants, not monotonic elapsed durations. Cancellation is reported when an accepted operation observes requested cancellation; an unrelated backend timeout is a failure. This snapshot does not report scheduler health.

Backup requests and completion

Use one coordinator and one normal writer per backup destination. A coordinator runs at most one generation at a time; it does not build a backlog.

Request while busy Result
Manual CreateGenerationAsync() Throws InvalidOperationException; an accepted failure propagates to the caller.
Schedule or normal-merge trigger Skips the new request; failures of accepted attempts go to the tree's logger.

Stop() disables automatic triggers and cancels scheduler waiting. It does not cancel a running generation or create a final one. Stop before calling WaitForLiveBackupAsync() while automatic operation is enabled; waiting drains active work and scheduler shutdown, but does not certify backup success.

Dispose() stops and waits too. Keep the coordinator inside the source tree's lifetime. If you need a final application checkpoint, finish application writers, stop triggers, await active work, then explicitly await CreateGenerationAsync(). CreateGeneration() and WaitForLiveBackup() are blocking equivalents; prefer their async forms in services.

Capture, integrity, and storage reference

Plain-tree memory capture and performance

Memory records are included by default, with deletion markers that hide older disk values. These choices apply to plain trees; transactional backups always use a frozen memory view and reject omission of memory records.

  • InMemoryMode = Live is the default. It streams mutable and read-only memory segments without forcing a rotation. Concurrent writes can affect the pass.
  • InMemoryMode = Snapshot rotates the mutable segment and waits for captured read-only segments to freeze. Normal merge maintenance may follow.
  • IncludeInMemoryRecords = false captures disk and bottom segments only. Memory-only updates and deletions are absent; use this only when that omission is intentional.

Physical disk files are copied as-is. RecordBatchCompression configures the memory batch independently; defaults are Zstd level 0 with 1 MiB blocks. MaxConcurrentFileTransfers defaults to 8.

LastOpIndex is a sequence high-water mark, not an exact capture boundary. Restore assigns operation indexes while rebuilding memory records. Coordinate replay or replication through an application checkpoint rather than interpreting this field as a complete write cut.

Local files, integrity, and publication durability

The local root contains manifest.json, completed catalogs in generations/, reusable disk files in data/, and memory batches in records/. Copying a catalog alone does not copy its data.

The local provider fsyncs new file contents and persists names before publishing the catalog and latest-generation manifest. Retention begins after durable publication and persists catalog retirement before reclaiming its files. Restore fsyncs copied immutable files and persists built-in WAL names before publishing database metadata. These barriers run at backup and restore boundaries.

Local catalogs use version 2 for plain trees and version 3 for transactional trees. They store SHA-256 digests for segment files and memory batches; the catalogs themselves are not checksummed. Restore also checks lengths, record counts, and consecutive record indexes. Reused files keep their original digest.

Legacy version 1 local catalogs remain readable with length and record-count checks, but lack stored content digests. A new generation recopies legacy files from the source to establish checksums. Local and object-storage layouts are separate formats.

Existing database metadata causes LiveBackupRestoreTargetAlreadyExistsException; that guard is not a complete empty-directory check. Integrity failures stop restore before publishing metadata. Remove partial output before retrying into unused data and WAL directories.

Custom storage integrations

For an object service, use IBackupObjectStorage and the built-in object-storage provider. Its contract requires durable committed writes, consistent reads, conditional publication, listing, and deletion. Read the adapter contract before implementing it.

Implement ILiveBackupStore and ILiveBackupSource directly when you need to own a different format or storage workflow. Preserve tree kind, file ordering, finalized digests, batch lengths, and counts. File operations may run concurrently. Attach restore protection with new LiveBackupGeneration(lease) or LiveBackupGeneration.CreateWithAsyncLease(lease).

Offline full copy and rebuild alternatives

For an offline copy, finish writers and scans, stop backup scheduling, await backup and merge work, and dispose the tree. With WAL disabled, merge memory records to disk before closing. Copy the entire closed data directory and every separately configured WAL location, including metadata, multipart files, WAL chunks, and companion tails.

Keep the factory configuration with the copy and verify an independent open. A rebuild from authoritative application input is also suitable for derived indexes and caches when you retain the replay position needed to reconstruct them.