schfs: snapshots

A snapshot is a tree root that is kept. The B-tree is copy-on-write, so
the blocks a committed root reaches are already immutable -- nothing
overwrites a block a committed tree can reach -- and retaining one costs
a record: (SNAP_OBJECTID, KEY_SNAP, id) -> struct schfs_snap.

The record names the root as it was *before* the record was added.
Inserting it rewrites the path under copy-on-write and produces a new
root, so a snapshot that included its own creation would name a block
nobody had written yet.

What makes it survive is the derivation. There is no free-space map on
disk; it is rebuilt at mount by walking the tree, and that walk now
covers every snapshot root as well as the live one. Without it a block
reachable only from a snapshot looks free, gets handed out, and
overwrites the data the snapshot exists to keep -- silently, surfacing
only when the snapshot is read. A snapshot's own tree may hold older
snapshot records; those are deliberately not followed, or deleting a
snapshot could be undone by an older one's memory of it.

Runtime frees are suspended while a snapshot exists. Knowing which freed
blocks a snapshot still needs requires per-extent reference counting,
which is a design and not a flag; until that exists the safe answer is to
free nothing and let the next mount's derivation reclaim what is
genuinely unreachable. Allocation is unaffected and stays correct -- it
works from the in-use bitmap the derivation filled. The running
free-blocks *counter* does drift from the truth while a snapshot is
present, which is cosmetic and is reported by the test rather than
asserted away.

Tested in schfs_objtest: after a snapshot, the live tree shows new
contents while the snapshot still shows the old ones; a file deleted from
the live tree is still readable through the snapshot; deleting the
snapshot removes the record and the space comes back at the next mount.

schfs-check passes including the round-trip; bootcheck 108 [ok].

Not yet: snapshots are not reachable by name from userspace -- there is
no mount-a-snapshot path, so this is the format and the mechanism, not
the feature a user sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-10 19:10:24 +02:00
commit 6e2e9e8d05
6 changed files with 333 additions and 5 deletions

View file

@ -552,6 +552,106 @@ main(void)
"the tree survives being filled to the brim");
}
/* ---- 8b. snapshots ---- */
{
// A snapshot is only worth anything if the data it names survives the
// live tree changing underneath it. So: write, snapshot, overwrite,
// and then read *through the snapshot's root* and expect the old bytes.
int r;
uint64_t ino = mkfile(VOL.root_objectid, "snapfile");
ok(ino != 0, "created a file to snapshot");
const char before[] = "BEFORE-THE-SNAPSHOT";
schfs_file_write(&VOL, ino, 0, before, sizeof before, DATABLK);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
// The snapshot names the committed root, which is the state just
// written -- not the root this call produces.
r = schfs_snap_create(&VOL, 1, "first", 5);
ok(r == 0, "took a snapshot");
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_snap sn;
ok(schfs_snap_get(&VOL, &c, 1, &sn) == 0, "the snapshot record reads back");
// Change the live tree: overwrite the file completely.
const char after[] = "AFTER-THE-SNAPSHOT!";
schfs_file_write(&VOL, ino, 0, after, sizeof after, DATABLK);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
char buf[64];
memset(buf, 0, sizeof buf);
schfs_cursor_init(&c, &VOL.tree, CURBLK);
schfs_file_read(&VOL, &c, ino, 0, buf, sizeof after, DATABLK);
ok(memcmp(buf, after, sizeof after) == 0,
"the live tree shows the new contents");
// Read through the retained root. Same volume, same block device, a
// tree pointed at the old root -- which is all a snapshot is.
struct schfs_vol snapvol = VOL;
snapvol.tree.root = sn.root;
snapvol.tree.root_crc = sn.root_crc;
snapvol.tree.root_gen = sn.root_generation;
memset(buf, 0, sizeof buf);
schfs_cursor_init(&c, &snapvol.tree, CURBLK);
long got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK);
ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0,
"the snapshot still shows the OLD contents");
if (memcmp(buf, before, sizeof before) != 0)
printf(" (snapshot read back \"%s\")\n", buf);
// And the blocks it holds must not be considered free, or they get
// handed out and the snapshot rots silently.
// Exact agreement is not the invariant once a snapshot exists, and the
// number is the only thing that drifts. Allocation works from the
// in-use bitmap, which the derivation filled by walking the snapshot
// too, so a snapshot's blocks are never handed out. The running
// counter is not corrected for them, so it and the derivation disagree
// until the next mount. Cosmetic, and reported rather than asserted
// away.
uint64_t claimed = remount();
if (claimed != VOL.free_blocks)
printf(" (note: counter %llu, derived %llu -- the counter "
"drifts while a snapshot exists)\n",
(unsigned long long)claimed,
(unsigned long long)VOL.free_blocks);
// Deleting the file from the live tree must not disturb the snapshot.
schfs_dir_del(&VOL, VOL.root_objectid, "snapfile", 8);
schfs_object_free(&VOL, ino);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
memset(buf, 0, sizeof buf);
snapvol = VOL;
snapvol.tree.root = sn.root;
snapvol.tree.root_crc = sn.root_crc;
snapvol.tree.root_gen = sn.root_generation;
schfs_cursor_init(&c, &snapvol.tree, CURBLK);
got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK);
ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0,
"a file deleted from the live tree is still in the snapshot");
// Drop it again, so what it was holding becomes reclaimable and the
// volume goes back to exact accounting for the checks that follow.
ok(schfs_snap_delete(&VOL, 1) == 0, "the snapshot can be deleted");
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
schfs_cursor_init(&c, &VOL.tree, CURBLK);
ok(schfs_snap_get(&VOL, &c, 1, &sn) != 0,
"a deleted snapshot is gone");
// Deleting it makes what it held unreachable, and the derivation at
// the next mount is what reclaims it -- there is no refcount to
// decrement. So the derived figure must be at least what the stale
// counter believed; check 9 below then proves exact agreement is back.
claimed = remount();
ok(VOL.free_blocks >= claimed,
"space the snapshot held is reclaimed at the next mount");
}
/* ---- 9. the map and its counter still agree ---- */
{
uint64_t claimed = remount();