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:
parent
58296b3376
commit
6e2e9e8d05
6 changed files with 333 additions and 5 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue