Backup and Restore
A backup is a consistent copy of the store's files, taken without any graph lock: writers go
on while it copies. It holds the latest snapshot and the WAL after it, up to the last durable
commit (a commit whose fsync completed). While it copies, the files are pinned against prune.
Every checksum of what it copies is read in the store first and again in the copy: a backup never
copies damage silently, and never leaves a copy that does not read back (see
Every checksum, twice).
#![allow(unused)] fn main() { use graphersal::persist::{BackupMode, RecoveryTarget, Store, StoreOptions}; let store = Store::open("data", StoreOptions::new())?; store.backup("backup")?; // an empty or new directory: a FULL backup // ... commits ... let report = store.backup("backup")?; // the same directory again: an INCREMENT println!("{} -> {} ({} bytes)", report.previous_commit_seq, report.commit_seq, report.bytes); store.backup_with("weekly", BackupMode::Full)?; // always full (the directory must be empty) let past = Store::open_read_only("backup", RecoveryTarget::CommitSeq(3))?; // any commit it holds Store::restore_backup("backup")?; // the original is lost: continue from the backup let live = Store::open("backup", StoreOptions::new())?; Ok::<(), Box<dyn std::error::Error>>(()) }
graphersal store backup data/ /mnt/backup/data # full the first time, incremental afterwards
graphersal store backup data/ /mnt/backup/data # "nothing new since commit N" when current
graphersal store backup data/ weekly/ --full # always full (an empty or new directory)
graphersal store backup data/ data.zip --zip # one ZIP archive (always full)
graphersal store info /mnt/backup/data # "backup up to commit N, taken T"
graphersal store prune /mnt/backup/data --up-to 5000 # the backup's own retention
graphersal store restore /mnt/backup/data # make it the live store (same graph id)
graphersal store restore data.zip restored/ # unpack a ZIP backup as the live store
$ graphersal store backup data/ backup/
Full backup into backup/: snapshot 3, the WAL up to commit 3 (1 segment(s)); 8 file(s), 9605 bytes.
The backup is read-only (graphersal --graph backup/ opens it so); `graphersal store restore backup/` makes it the live store.
$ graphersal store backup data/ backup/
Incremental backup into backup/: commits 4..4 (1 new); copied 0 snapshot(s), 0 WAL segment(s), 83 bytes appended to the last segment; 83 bytes in total.
Store::backup_dir(dir, target) (and backup_dir_with, backup_zip_dir) back up a store
without opening it: a backup works while a dev server or another program has the store open.
Every checksum, twice
A backup (full, increment, ZIP) checks everything it copies in the store before it writes
anything: both GRAPH copies, the marks file, the snapshot (manifest, schema.json, every
segment and chunk, the files it references) and every WAL record (header and body checksum, the
segment headers, commit continuity, a missing or emptied newest segment). After writing it reads
the copy back the same way; a ZIP archive is read back entry by entry through the same handle
(backup_zip takes Read + Write + Seek: a file opened for reading and writing, a
Cursor<Vec<u8>>).
- Damage in the store stops the backup before a byte is written:
PersistError::BackupDamagedwithside: DamageSide::Storeand the problems asverifylists them (file, byte, reason). Repair the store, never the backup:graphersal store repair <dir> --to <new_dir>. While the store is open in a process whose graph is intact (the dev server, a Python program), save that graph first with a backup from memory. - A copy that does not read back (a bad backup disk) is
side: DamageSide::Backup: a new backup directory is removed, an increment is rolled back to the backup's old state. The store is fine; check the backup's disk and back up again. - Redundant metadata (one
GRAPHcopy, one copy of a manifest's fixed part, the derivedmarksfile) is a warning (BackupReport::warnings, printed by the CLI): the backup is intact (two freshGRAPHcopies, the manifest with the intact copy in both places,marksrebuilt from the WAL it holds). Plan a repair of the store. - In an open store, damage a backup finds is damage found while the store is open: the store's damage policy applies (by default it turns read-only).
$ graphersal store backup data/ backup/
Error: The backup stopped: the store data/ is damaged: wal/00000000000000000001.wal at byte 64: record of commit 1: checksum mismatch; nothing of the backup into backup/ was kept
Help: Repair the MAIN store, never the backup: graphersal store repair data/ --to <new_dir> (Store::repair_dir) builds a verified, repaired copy in a new directory and reports what was repaired, lost and diverged; the damaged files are never changed. The full damage report: graphersal store verify data/. Existing backups stay valid: keep them until the repaired store is backed up.
A backup from memory
When damage was found while the store is open (by a backup, a checkpoint, verify), its graph in
memory is still intact: it was verified when the store was opened and changed only by commits
since. store.backup_from_memory(target, mode) writes that graph into a backup directory with
the streaming encoder (under the graph's read lock; commits wait meanwhile) and reads or writes
nothing in the store's directory:
- one snapshot at the graph's commit, an empty WAL segment after it,
GRAPHwith the backup marker and the store's identity, lineage and creation parameters; - into an empty or new directory a full backup; into a backup of the same store an increment (its snapshot is newer than everything the backup holds; a later normal increment of it needs a full backup and says so);
- the result is an ordinary backup:
graphersal store restore <dir>makes it the live store (same graph id),store forka new one. Marks are not carried (like a fork).
It is refused while no damage was found (a normal backup is the right tool and checks every
checksum), for a store opened in maintenance mode (its graph was salvaged from donors: use
repair_to) and for a backup opened read-only. Where: Rust Store::backup_from_memory; the dev
server's Store menu (Back up from memory) and POST /api/store/backup {"from_memory": true};
Python store.backup(path, from_memory=True). The graphersal store backup command runs in a
new process, so --from-memory there explains where to do it.
store = graphersal.Store.open("data")
try:
store.backup("/mnt/backup/data")
except graphersal.GraphersalError as err: # "the store data is damaged: ..."
store.backup("/mnt/backup/saved", from_memory=True)
A backup is marked and read-only
A backup's GRAPH file says "a backup of graph X up to commit N, taken at T". It keeps the
original's graph_id and lineage. It opens read-only: Store::open refuses it
(PersistError::IsBackup), so nothing writes to it by accident.
Store::open_backup(dir, options)is a read-onlyStoreover it: info, listings, views at any commit it holds (read_only_at), fork, verify, export and backups of it work; every write is refused.store.backup_marker()returns the marker.Store::open_read_only(dir, target)loads any commit it holds.graphersal --graph backup/(REPL,-e,--server) opens it read-only with a note:note: backup/ is a BACKUP (up to commit 4, taken 2026-10-08T07:05:09Z): opened READ-ONLY, writes are refused. Help: ...(the help namesstore restoreandstore fork).- The dev server shows a "Backup: read-only" banner; its Store menu offers View and Fork only.
Restore
- In place:
Store::restore_backup(dir)(graphersal store restore <BACKUP_DIR>) clears the marker: the directory becomes the live store with the same graph id. This is the case "the original is lost, continue from the backup". Stop anything that has the backup open first. - As an independent copy that leaves the backup as it is: fork it
(
graphersal store fork backup/ copy/, a new graph id). - From a ZIP (
persist-zip):Store::restore_zip(reader, dir)(graphersal store restore data.zip restored/) unpacks it as the live store (unpacking is the explicit restore).
Incremental backups
A backup into a directory that holds a backup of the same store copies only what the backup does not hold yet:
- newer snapshots, new WAL segments, and the new end of its last WAL segment, after checking that the backup's WAL is a prefix of the store's (the last record's frame);
- every file it copies is checked in the store first and read back in the backup (every chunk and record checksum), and the commits must continue the backup's without a gap or an overlap;
- the increment runs under an
INTENTfile in the backup directory: interrupted (a crash, a full disk), it is rolled back at the next backup, restore or prune of the backup. The backup is a valid store after every step; - the
BackupReportsays what was copied (previous_commit_seq,commit_seq,snapshots_copied,segments_copied,tail_bytes,bytes,lineage_changed); with nothing new it copies nothing.
After an in-place rollback of the store, the next increment follows the store's new lineage
when the rollback's target is at or after the backup's position. A rollback behind the
backup's position is refused: the backup holds history the store moved to its attic, so it is
kept as it is, the archive of the abandoned history; take a full backup into a new directory
(--full) for the new one. "Behind" counts every rollback since the backup: a
rollback of a later lineage to before that lineage's own start branches from the backup's lineage
at that earlier commit. An attic restore re-splits the WAL segment the rollback cut, so the next
increment may find its last segment no longer a prefix of the store's and asks for a full
backup.
Only the same store continues a backup. Every store has a store id
(creation parameter), recorded in each of its backups; an increment
requires the same one. A fork, an attic fork, a repair or a store convert copy shares the
original's lineage history up to the copy, but it is a different store with a new id: its
increment into the original's backup is refused, take a full backup into a new directory. An
in-place rollback, an attic restore and a restored backup (store restore, from a directory or a
ZIP) keep the id: a restored backup is the store and continues its other backups.
Refused
PersistError::BackupRefused leaves the backup unchanged. It is returned for:
- a backup of another graph;
- a backup of a different store: a fork, repair or copy of the backed-up store (its store id differs; above);
- a directory that holds a store that is not a backup (a live store is never written to);
- a non-empty directory without a store, or
--fullinto a non-empty directory; - a rollback of the store behind the backup's position (above);
- a pruned gap: the store's
pruneremoved WAL the backup still needs. Backups do not pin the store's WAL againstprune: back up more often than you prune, or take a full backup into a new directory.
Retention of backups
A backup has its own retention: Store::prune_backup(dir, up_to) (graphersal store prune <BACKUP_DIR> --up-to N) prunes it by the store's rules (the last two snapshots stay); the next
increment continues it. Keep several generations by backing up into several directories (for
example one per week with --full).
ZIP archives
With the persist-zip feature, store.backup_zip(archive) writes a full backup as one archive:
stored entries (the chunks are already compressed), zip64, every file's CRC. The archive is read
back through the same handle and checked (every entry's CRC-32, every checksum of the store files
in it), so it takes Read + Write + Seek: a file opened for reading and writing, or a
std::io::Cursor<Vec<u8>> for an upload. The archive's GRAPH carries the backup marker;
Store::restore_zip clears it. ZIP backups are always full. The CLI refuses an existing target
file and removes the archive when the backup fails.
What is not in a backup
The attic (rolled-back history), the LOCK and BACKUP lock files, and any commit after the
durable end at the moment of the copy (it is in the next increment).