Transaction groups commit from a schfsd thread every 5 seconds, on sync and fsync, on unmount and shutdown, and early when freed-but-pending blocks crowd the free ones -- the contract ext4 and ZFS have. A crash still lands on the last group whole, and loses at most those seconds. Committing before every write(2) returned cost two cache flushes and an uberblock a call: 4 KB writes go from 2.8 to 6.6 MB/s on AHCI, and from 66 KB/s to 3.9 MB/s on QEMU's IDE disk. Filling the disk made the root volume unmountable, on the last commit as well: a tree update that ran out of blocks halfway was published by the next commit. 1% of the volume (1 to 64 MiB) is now a metadata reserve, as btrfs and ZFS keep one; data stops at it with ENOSPC as a short write, new names at half of it, and deletes may use the rest. A write that runs out part way records what it wrote in the inode rather than leaving blocks past an end of file nothing moved. Deleting a large file goes in 4 MiB steps from the end with a commit allowed between them, as ext4 restarts its transaction through a long truncate. Deleting 80 MB on a full disk ran out of room halfway and leaked the rest. The VFS's dirunlink called the object layer's whole unlink, which freed the object with its last name, so a file removed while open lost its data under the reader. It removes the name only now, and the object is freed on the last iput. An object still open when the machine went down is found by the mount's space scan and freed, like ext4's orphan list. The allocation bitmap has a second level of pointer pages, so the mount ceiling is 64 TiB instead of 64 GiB, and the freed-this-group bitmap has pages only where frees happened: a 1 TiB volume takes 32 MiB. A freed block written after the newest snapshot is reused at once (ZFS's birth-time rule, so bfree carries the block's generation); only older ones are kept while a snapshot exists. Deleting one snapshot no longer stops retaining for the others, which let their blocks be handed out again. Checked under QEMU: crash after sync, after 8 s and within 5 s; fill to ENOSPC, rm, remount; unlink while open; an orphan freed at the next mount; a 1 TiB volume. schfs-check passes, with a new test for the snapshot rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
245 lines
12 KiB
C
245 lines
12 KiB
C
// SchFS copy-on-write B-tree.
|
|
//
|
|
// This is the load-bearing piece of the whole filesystem: inodes, directory
|
|
// entries, extent maps and indexes are all just records in this one tree, so a
|
|
// subtle bug here is a bug in everything. It is therefore written as portable C
|
|
// against an abstract block device, and exercised on the host by
|
|
// tools/schfs_test.c before it is ever run inside the kernel.
|
|
//
|
|
// Copy-on-write means no block that is part of the *committed* tree is ever
|
|
// modified. An update copies the path from the leaf back up to the root, and
|
|
// the caller then publishes the new root. A crash therefore lands on either the
|
|
// old tree or the new one, never a mixture — which is why there is no journal
|
|
// and no fsck.
|
|
//
|
|
// "Committed" is the operative word: a block first written during the
|
|
// transaction group now being built is not reachable from any published root,
|
|
// so it may be rewritten in place. See schfs_btree.generation.
|
|
|
|
#ifndef SCHFS_BTREE_H
|
|
#define SCHFS_BTREE_H
|
|
|
|
#include "schfs.h"
|
|
|
|
// Block device the tree lives on. The kernel supplies one backed by the buffer
|
|
// cache; the test harness supplies one backed by a plain file or by memory.
|
|
struct schfs_bdev {
|
|
void *ctx;
|
|
int (*bread)(void *ctx, uint64_t blk, void *buf);
|
|
int (*bwrite)(void *ctx, uint64_t blk, const void *buf);
|
|
// Allocate a block for copy-on-write. Must never return a block that is
|
|
// reachable from the last committed root.
|
|
uint64_t (*balloc)(void *ctx);
|
|
// Release a block that the new tree no longer references. Freeing may only
|
|
// take effect at commit, since the old tree must stay readable until then.
|
|
// `gen` is the group that wrote the block, which says whether a snapshot
|
|
// can still be holding it -- see schfs_space_free.
|
|
void (*bfree)(void *ctx, uint64_t blk, uint64_t gen);
|
|
// Optional: allocate n physically contiguous blocks, returning the first,
|
|
// or 0 if that cannot be satisfied. This is what lets a file become one
|
|
// extent rather than one per block, and it has to be a separate entry point
|
|
// because an allocator with a free list will happily return scattered
|
|
// blocks from balloc. NULL means unsupported; callers then fall back to
|
|
// allocating singly and accept the extra extent records.
|
|
uint64_t (*balloc_run)(void *ctx, uint64_t n);
|
|
// Optional: make every write issued so far durable, and do not return until
|
|
// it is. NULL means the backing store has no volatile stage to worry about
|
|
// -- a memory-backed device, or a host file the tools fsync themselves.
|
|
//
|
|
// This is the one hook the format's crash-safety argument actually rests
|
|
// on. Copy-on-write guarantees that a tree is never half-modified, but only
|
|
// if the blocks a root points at reach the medium *before* the block that
|
|
// names that root. Nothing in the ordering of bwrite calls guarantees that:
|
|
// a drive with a write cache is free to complete them in any order it
|
|
// likes. Without a barrier between the two, a power cut lands on a newest
|
|
// uberblock whose tree was never written -- exactly the failure copy-on-
|
|
// write is supposed to make impossible.
|
|
int (*bflush)(void *ctx);
|
|
};
|
|
|
|
struct schfs_btree {
|
|
struct schfs_bdev *dev;
|
|
uint64_t root; // block number of the current root
|
|
uint32_t root_crc;
|
|
uint64_t root_gen; // generation stamped in the root block itself
|
|
// The transaction group being built. Every block written by a mutation is
|
|
// stamped with this, and a block already carrying it is rewritten in place
|
|
// rather than copied — it cannot be part of a committed tree. The volume
|
|
// layer advances it on every commit; nothing else may touch it.
|
|
uint64_t generation;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scratch buffers
|
|
//
|
|
// Nothing in this file may put a block on the stack. A SchFS block is 4096
|
|
// bytes and so is the entire kernel stack (KSTACKSIZE), so even one such local
|
|
// overflows it — the kstack canary caught exactly that on the first boot of the
|
|
// read path. Mutations need three blocks live at once (the node being edited,
|
|
// the sibling a split produces, and a repacking buffer), so callers hand in
|
|
// three and the tree borrows them.
|
|
//
|
|
// The three must not alias, and their contents do not survive across calls.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct schfs_ctx {
|
|
void *a; // node currently being edited
|
|
void *b; // sibling produced by a split
|
|
void *c; // repacking scratch for leaves
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cursor
|
|
//
|
|
// The read path is driven through a cursor for the same reason: a cursor holds
|
|
// exactly one block buffer, supplied by the caller, and remembers the path as
|
|
// block numbers, re-reading a parent on the way back up. Walking a tree of any
|
|
// depth therefore costs one page rather than one page per level.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct schfs_path_ent {
|
|
uint64_t blocknr;
|
|
uint32_t crc;
|
|
int index; // internal: child descended into; leaf: item
|
|
};
|
|
|
|
struct schfs_cursor {
|
|
struct schfs_btree *t;
|
|
void *blk; // caller-owned, SCHFS_BLKSIZE bytes
|
|
int depth; // path[depth] is the leaf
|
|
int valid; // 0 once the walk has run off either end
|
|
// Which block `blk` currently holds, as far as this cursor knows, or 0 for
|
|
// "nothing yet". See cur_load: re-reading a block that is already in the
|
|
// buffer costs a 4 KB copy and a CRC32c over the whole 4 KB, and the code
|
|
// was paying both on every single access to the current record.
|
|
//
|
|
// Cleared by schfs_cursor_init rather than trusted across cursors, because
|
|
// the buffer belongs to the caller and the *next* caller's contents are not
|
|
// this cursor's business. Cross-checked against the block header on every
|
|
// use, so a second cursor sharing the same page cannot fool it either.
|
|
uint64_t resident;
|
|
uint64_t epoch; // schfs_tree_epoch when it was loaded
|
|
struct schfs_path_ent path[SCHFS_MAX_LEVEL + 1];
|
|
};
|
|
|
|
// Every mutation bumps this, retiring every cursor's cached block. See
|
|
// cur_load in btree.c.
|
|
extern uint64_t schfs_tree_epoch;
|
|
|
|
// `blk` must point at SCHFS_BLKSIZE bytes that outlive the cursor.
|
|
void schfs_cursor_init(struct schfs_cursor *c, struct schfs_btree *t, void *blk);
|
|
|
|
// Position at the first key >= start, or at the very first key if start is
|
|
// NULL. Returns 0 if positioned, -1 if the tree holds no such key, -2 on
|
|
// corruption.
|
|
int schfs_cursor_seek(struct schfs_cursor *c, const struct schfs_key *start);
|
|
|
|
// Step. Return 0, -1 at the end of the tree, -2 on corruption.
|
|
int schfs_cursor_next(struct schfs_cursor *c);
|
|
int schfs_cursor_prev(struct schfs_cursor *c);
|
|
|
|
// Current record. Both pointers are invalidated by the next seek/next/prev.
|
|
// Return NULL when the cursor is not positioned.
|
|
const struct schfs_key *schfs_cursor_key(struct schfs_cursor *c);
|
|
const void *schfs_cursor_val(struct schfs_cursor *c, unsigned *len);
|
|
|
|
// Ordering over keys: (objectid, type, offset). Returns <0, 0, >0.
|
|
int schfs_key_cmp(const struct schfs_key *a, const struct schfs_key *b);
|
|
|
|
uint32_t schfs_crc32c(uint32_t crc, const void *buf, unsigned len);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node accessors
|
|
//
|
|
// Plain views over the on-disk layout in schfs.h. Exposed because the space
|
|
// manager walks the tree by block number rather than by key, and a second,
|
|
// slightly different copy of these three lines is exactly the sort of thing
|
|
// that ends up disagreeing with the first.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#define SCHFS_HDR(b) ((struct schfs_node_hdr *)(b))
|
|
#define SCHFS_KPTRS(b) ((struct schfs_kptr *) \
|
|
((char *)(b) + sizeof(struct schfs_node_hdr)))
|
|
#define SCHFS_ITEMS(b) ((struct schfs_item *) \
|
|
((char *)(b) + sizeof(struct schfs_node_hdr)))
|
|
|
|
// Read one tree block into `out` and validate it completely: magic, the block's
|
|
// self-pointer, its checksum, and — when want_crc is non-zero — that the
|
|
// checksum is the one its parent recorded. Returns 0 or -2.
|
|
int schfs_node_read(struct schfs_bdev *dev, uint64_t blk, uint32_t want_crc,
|
|
void *out);
|
|
|
|
// Create an empty tree (a single empty leaf) and set t->root.
|
|
int schfs_btree_create(struct schfs_btree *t, struct schfs_bdev *dev,
|
|
struct schfs_ctx *x);
|
|
|
|
// Look a key up. On success copies at most *len bytes of the value into buf and
|
|
// stores the true size in *len. Returns 0 on success, -1 if absent, -2 on a
|
|
// corrupt or unreadable block. Uses x->a as its block buffer.
|
|
int schfs_btree_lookup(struct schfs_btree *t, struct schfs_ctx *x,
|
|
const struct schfs_key *k, void *buf, unsigned *len);
|
|
|
|
// Same, through a cursor the caller already has positioned somewhere else.
|
|
int schfs_btree_lookup_cur(struct schfs_cursor *c, const struct schfs_key *k,
|
|
void *buf, unsigned *len);
|
|
|
|
// Greatest key <= k, which is how an extent covering a file offset is found:
|
|
// extents are keyed by the file offset they start at, so the record governing
|
|
// byte N is the last one at or before (ino, EXTENT, N). Leaves the cursor
|
|
// positioned on it. Returns 0, -1 if every key in the tree is greater, -2 on
|
|
// corruption.
|
|
int schfs_btree_lookup_le(struct schfs_cursor *c, const struct schfs_key *k);
|
|
|
|
// Insert or replace. Copies the path, so t->root changes on success.
|
|
// Returns 0, -1 if the value is too large to store inline, -2 on I/O or
|
|
// corruption, -3 if the device is out of blocks.
|
|
int schfs_btree_insert(struct schfs_btree *t, struct schfs_ctx *x,
|
|
const struct schfs_key *k, const void *val, unsigned len);
|
|
|
|
// Remove a key. Emptied nodes are freed and the root is collapsed when it drops
|
|
// to a single child, so a tree that is emptied returns to one block.
|
|
// Returns 0, -1 if absent, -2 on corruption, -3 out of space.
|
|
int schfs_btree_delete(struct schfs_btree *t, struct schfs_ctx *x,
|
|
const struct schfs_key *k);
|
|
|
|
// Walk records from `start` (inclusive) onward. This is what a directory read
|
|
// uses: every entry of directory D is a key in [(D,DIRENT,0), (D,DIRENT,~0)],
|
|
// so listing a directory costs the size of that directory rather than the size
|
|
// of the filesystem. fn returns non-zero to stop.
|
|
//
|
|
// WARNING: the `val` pointer handed to fn points *into the cursor's block
|
|
// buffer*. Any other tree operation the callback performs may overwrite it —
|
|
// including one on a different cursor, if the two were given the same buffer.
|
|
// A callback that looks anything up must copy what it needs first. The key is
|
|
// already copied out for you; the value is not, because it can be a kilobyte
|
|
// and most callbacks consume it immediately.
|
|
int schfs_btree_range(struct schfs_cursor *c, const struct schfs_key *start,
|
|
int (*fn)(const struct schfs_key *k,
|
|
const void *val, unsigned len, void *arg),
|
|
void *arg);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Host-only facilities
|
|
//
|
|
// These recurse and keep a block per level, which is fine in a tool and fatal
|
|
// in the kernel. Compiling them out of the kernel makes that a link error
|
|
// rather than a comment nobody reads.
|
|
// ---------------------------------------------------------------------------
|
|
#ifdef SCHFS_HOSTED
|
|
|
|
// Walk every record in key order, calling fn for each. A non-zero return from
|
|
// fn stops the walk and is passed back.
|
|
int schfs_btree_iterate(struct schfs_btree *t,
|
|
int (*fn)(const struct schfs_key *k,
|
|
const void *val, unsigned len, void *arg),
|
|
void *arg);
|
|
|
|
// Full structural check: magic, self-pointer, checksum, level agreement, key
|
|
// ordering within and across nodes, that values lie inside their block, and
|
|
// that every child's first key is exactly the key its parent files it under.
|
|
// Returns 0 if the tree is sound, non-zero on the first problem found.
|
|
int schfs_btree_check(struct schfs_btree *t);
|
|
|
|
#endif /* SCHFS_HOSTED */
|
|
|
|
#endif /* SCHFS_BTREE_H */
|