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

@ -39,6 +39,7 @@
#define SCHFS_KEY_ATTR 4 // (ino, ATTR, namehash) -> attribute value
#define SCHFS_KEY_INDEX 5 // (idxid, INDEX, valuehash) -> objectid
#define SCHFS_KEY_CSUM 6 // (CSUM, CSUM, diskblk) -> uint32 CRC32c
#define SCHFS_KEY_SNAP 7 // (SNAP, SNAP, id) -> struct schfs_snap
// Data-block checksums live under one reserved object id, exactly as btrfs
// keeps a checksum tree. Tree nodes carry their checksum in their parent
@ -46,6 +47,11 @@
// records "end-to-end checksums" would only cover metadata, which is the part
// least likely to rot.
#define SCHFS_CSUM_OBJECTID 1ULL
// Snapshots live under their own reserved objectid, as ordinary records in the
// live tree. A snapshot is nothing more than a tree root that is kept: the
// B-tree is copy-on-write, so the blocks a root reaches are already immutable
// as long as nothing frees them. Keeping one costs a record.
#define SCHFS_SNAP_OBJECTID 2ULL
// Object ids below this are reserved; the root directory is the first real one.
#define SCHFS_FIRST_OBJECTID 16ULL
@ -122,6 +128,32 @@ struct schfs_item {
#define SCHFS_VERSION 1
#define SCHFS_LABEL_MAX 32
// A retained tree root.
//
// The root a snapshot names is a *previous* version of the live tree, and it is
// reached exactly as the live one is -- same node format, same checksums. What
// makes it survive is schfs_space_scan walking it too, so its blocks are never
// handed out again.
//
// The record is stored in the live tree, which means creating one rewrites the
// path to it under copy-on-write and produces a new live root. That is correct
// and is why the snapshot names the root as it was *before* the record was
// added: a snapshot that included its own creation would have to refer to a
// block that does not exist yet.
#define SCHFS_SNAP_NAME_MAX 64
// Set on a volume that has at least one snapshot. See schfs_space_free.
#define SCHFS_SPACE_RETAIN_ALL 1
struct schfs_snap {
uint64_t root; // block number of the retained tree root
uint64_t root_generation;
uint32_t root_crc; // checksum of that root block
uint32_t pad0;
uint64_t created; // generation the snapshot was taken at
char name[SCHFS_SNAP_NAME_MAX];
};
struct schfs_uberblock {
uint32_t magic; // SCHFS_MAGIC
uint32_t crc; // CRC32c of this struct, this field zeroed