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
459 lines
19 KiB
C
459 lines
19 KiB
C
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
//
|
|
// SchFS volume layer: the uberblock ring.
|
|
//
|
|
// A ring of SCHFS_UBER_COUNT slots is written round-robin, each stamped with a
|
|
// transaction number and a checksum. Mounting reads every slot and adopts the
|
|
// highest-numbered one that validates.
|
|
//
|
|
// That single rule replaces journalling entirely. A commit writes one block; it
|
|
// either lands whole or it does not. If it does not, its checksum fails, the
|
|
// previous slot is still the highest valid one, and the volume comes up at the
|
|
// previous transaction with no replay and no fsck. Rotating through many slots
|
|
// rather than overwriting one means a run of bad sectors under the current slot
|
|
// cannot take the volume with it.
|
|
|
|
#include "schfs_int.h"
|
|
|
|
#ifdef SCHFS_HOSTED
|
|
#include <string.h>
|
|
#else
|
|
#include "types.h"
|
|
#include "defs.h"
|
|
#endif
|
|
|
|
// Checksum an uberblock with its own crc field zeroed, the same convention the
|
|
// tree nodes use.
|
|
static uint32_t
|
|
uber_crc(struct schfs_uberblock *u)
|
|
{
|
|
uint32_t saved = u->crc;
|
|
u->crc = 0;
|
|
uint32_t c = schfs_crc32c(0, u, sizeof(*u));
|
|
u->crc = saved;
|
|
return c;
|
|
}
|
|
|
|
// What this uberblock is, which is not the same question as whether it is good.
|
|
//
|
|
// This used to be one predicate returning "valid" or not, and a version
|
|
// mismatch answered the same as a failed checksum. That conflates two states
|
|
// the caller has to treat in opposite ways:
|
|
//
|
|
// A damaged uberblock means TRY THE NEXT ONE. That is what the ring is for,
|
|
// and falling back to an older generation is exactly right -- it is crash
|
|
// recovery, and the older root is a consistent filesystem.
|
|
//
|
|
// An uberblock this driver is too old to understand means STOP. Falling back
|
|
// mounts a *stale* filesystem: every commit made since the volume was
|
|
// upgraded is silently discarded, and the mount succeeds, so nobody finds
|
|
// out. That is data loss reported as success, which is the worst outcome
|
|
// available here.
|
|
//
|
|
// The order of the checks is the order of the questions. Is it ours (magic);
|
|
// is it intact (crc); can we read it (version, features); does it make sense
|
|
// (root in range). Compatibility is asked before sanity on purpose -- the
|
|
// fields a sanity check reads are only meaningfully placed if the format is
|
|
// one we know.
|
|
enum uber_state {
|
|
UBER_BAD = 0, // not SchFS, or damaged -- keep looking
|
|
UBER_OK, // ours, and fully usable
|
|
UBER_RO, // ours, sound, but carries ro_compat bits we lack
|
|
UBER_TOO_NEW, // ours, sound, and beyond this driver -- stop the scan
|
|
};
|
|
|
|
static enum uber_state
|
|
uber_check(struct schfs_uberblock *u)
|
|
{
|
|
if (u->magic != SCHFS_MAGIC)
|
|
return UBER_BAD;
|
|
if (uber_crc(u) != u->crc)
|
|
return UBER_BAD;
|
|
|
|
// From here the block is certainly ours and certainly landed intact, so
|
|
// anything wrong with it is a statement about the *format*, not the media.
|
|
if (u->version > SCHFS_VERSION)
|
|
return UBER_TOO_NEW;
|
|
// A different block size is not damage either: every offset would be read
|
|
// wrong, which is precisely what an incompatible format means.
|
|
if (u->blocksize != SCHFS_BLKSIZE)
|
|
return UBER_TOO_NEW;
|
|
if (u->feature_incompat & ~SCHFS_FEAT_INCOMPAT_SUPP)
|
|
return UBER_TOO_NEW;
|
|
|
|
// Self-consistency: a root inside the ring or past the end of the device is
|
|
// a corrupt uberblock that happens to checksum, not a usable one.
|
|
if (u->root < SCHFS_FIRST_BLOCK || u->root >= u->total_blocks)
|
|
return UBER_BAD;
|
|
|
|
// Read-only is a real mount, so it is decided last -- after everything that
|
|
// would have refused outright.
|
|
if (u->feature_ro_compat & ~SCHFS_FEAT_RO_COMPAT_SUPP)
|
|
return UBER_RO;
|
|
|
|
// feature_compat is deliberately not consulted. Unknown bits there are
|
|
// unknown on purpose; that is what the class means.
|
|
return UBER_OK;
|
|
}
|
|
|
|
static void
|
|
vol_from_uber(struct schfs_vol *v, struct schfs_uberblock *u, int slot)
|
|
{
|
|
v->tree.dev = &v->dev;
|
|
v->tree.root = u->root;
|
|
v->tree.root_crc = u->root_crc;
|
|
v->tree.root_gen = u->root_generation;
|
|
// The tree's `generation` is the transaction group being *built*, one past
|
|
// the newest committed one. Setting it to the committed root's generation
|
|
// instead would tell the tree that every block of the mounted filesystem
|
|
// belongs to the open transaction, and it would then rewrite the live tree
|
|
// in place — losing the very thing copy-on-write exists to preserve.
|
|
v->tree.generation = u->generation + 1;
|
|
|
|
v->total_blocks = u->total_blocks;
|
|
v->free_blocks = u->free_blocks;
|
|
v->next_objectid = u->next_objectid;
|
|
v->root_objectid = u->root_objectid;
|
|
v->generation = u->generation;
|
|
v->uber_slot = slot;
|
|
|
|
memmove(v->uuid, u->uuid, sizeof(v->uuid));
|
|
memmove(v->label, u->label, sizeof(v->label));
|
|
v->label[SCHFS_LABEL_MAX - 1] = '\0';
|
|
}
|
|
|
|
int
|
|
schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|
struct schfs_ctx *x, void *(*palloc)(void),
|
|
void (*pfree)(void *))
|
|
{
|
|
struct schfs_uberblock *u = (struct schfs_uberblock *)x->a;
|
|
int io_errors = 0;
|
|
int seen = 0; // valid uberblocks found, at any generation
|
|
uint64_t ceiling = ~0ULL; // consider only generations below this
|
|
int spaced = 0; // the allocation map is built and owned
|
|
uint64_t too_new = 0; // newest generation this driver cannot read
|
|
int ro_forced = 0; // the adopted root carries ro_compat bits
|
|
|
|
v->dev = *dev;
|
|
v->ctx = *x;
|
|
v->tree.dev = &v->dev;
|
|
v->recovered = 0;
|
|
|
|
// Walk the ring newest-first, and keep walking.
|
|
//
|
|
// This used to take the highest valid generation, check its tree, and give
|
|
// up if that failed. But an uberblock passing its own checksum only proves
|
|
// the uberblock landed -- not the tree it names. That is exactly the state
|
|
// a power cut used to leave behind before commit had a barrier, and it is
|
|
// still what a misdirected write or a bad sector under the root produces.
|
|
//
|
|
// A ring of SCHFS_UBER_COUNT slots exists precisely so there is somewhere
|
|
// to fall back *to*. Refusing the mount while 127 older, entirely intact
|
|
// roots sat on the disk was throwing away the only thing the ring is for.
|
|
for (;;) {
|
|
int best_slot = -1;
|
|
uint64_t best_gen = 0;
|
|
|
|
for (int i = 0; i < SCHFS_UBER_COUNT; i++) {
|
|
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + i, u) != 0) {
|
|
if (ceiling == ~0ULL)
|
|
io_errors++; // count each slot once, on the first sweep
|
|
continue; // an unreadable slot is not fatal: try the rest
|
|
}
|
|
{
|
|
enum uber_state st = uber_check(u);
|
|
if (st == UBER_TOO_NEW) {
|
|
// Recorded, not mounted, and not skipped past. Counted on
|
|
// the first sweep only, like io_errors: later sweeps walk
|
|
// the same slots with a lower ceiling.
|
|
if (ceiling == ~0ULL && u->generation > too_new)
|
|
too_new = u->generation;
|
|
continue;
|
|
}
|
|
if (st == UBER_BAD)
|
|
continue;
|
|
}
|
|
if (u->generation >= ceiling)
|
|
continue; // already tried, and rejected
|
|
if (best_slot < 0 || u->generation > best_gen) {
|
|
best_gen = u->generation;
|
|
best_slot = i;
|
|
}
|
|
}
|
|
if (ceiling == ~0ULL)
|
|
seen = best_slot >= 0;
|
|
|
|
if (best_slot < 0)
|
|
break; // nothing older left to try
|
|
|
|
// Refuse rather than mount something older than a root we could not
|
|
// read. The candidate below is a perfectly good filesystem; it is just
|
|
// not the *current* one, and mounting it would discard every commit
|
|
// made since the volume was written by a newer driver -- silently, and
|
|
// with the mount reporting success.
|
|
if (too_new > best_gen)
|
|
return -3;
|
|
|
|
ceiling = best_gen; // next round considers strictly older ones
|
|
|
|
// Re-read the winner rather than keeping a copy of it.
|
|
//
|
|
// An uberblock is a whole SCHFS_BLKSIZE block, and keeping one as a
|
|
// local put 4096 bytes on a kernel stack that is 4096 bytes in total --
|
|
// the canary in kalloc'd stacks caught it as "kstack overflow" on the
|
|
// very first boot. Re-reading costs one block read per candidate.
|
|
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + best_slot, u) != 0)
|
|
goto next;
|
|
{
|
|
enum uber_state st = uber_check(u);
|
|
if (st == UBER_TOO_NEW)
|
|
return -3; /* rewritten under us by a newer driver */
|
|
if (st == UBER_BAD)
|
|
goto next; /* changed under us, or a flaky device */
|
|
ro_forced = (st == UBER_RO);
|
|
}
|
|
|
|
vol_from_uber(v, u, best_slot);
|
|
v->readonly = 1; // until an allocation map exists, below
|
|
|
|
if (v->dev.bread(v->dev.ctx, v->tree.root, x->a) != 0)
|
|
goto next;
|
|
{
|
|
struct schfs_node_hdr *h = (struct schfs_node_hdr *)x->a;
|
|
if (h->magic != SCHFS_NODE_MAGIC || h->blocknr != v->tree.root)
|
|
goto next;
|
|
}
|
|
|
|
// Rebuild the allocation map from the tree just adopted. Without a
|
|
// palloc the volume stays read-only, which is exactly right for a tool
|
|
// that only inspects an image: it can then never allocate, rather than
|
|
// allocating from a map that says everything is free.
|
|
//
|
|
// The scan is also the deepest check this root gets: it visits every
|
|
// metadata block and verifies each against the checksum in its parent.
|
|
// A root that survives it is not merely present, it is whole -- so a
|
|
// scan failure is a reason to try the next candidate, not to give up.
|
|
if (palloc) {
|
|
if (!spaced) {
|
|
// Sizing failures are the caller's problem, not this root's:
|
|
// a volume too large to map or a machine out of pages will
|
|
// fail identically on every slot in the ring.
|
|
// A volume that has been opened before still holds the
|
|
// previous map, and schfs_space_init memsets it away -- so
|
|
// without this every re-open leaks one. schfs_objtest reopens
|
|
// the same volume nine times.
|
|
//
|
|
// Safe on a first open because a struct schfs_vol must be
|
|
// zeroed before first use, which is what release reads: a
|
|
// zeroed map has no pfree and no pages, so it does nothing.
|
|
schfs_space_release(&v->space);
|
|
int sr = schfs_space_init(&v->space, v->total_blocks, palloc, pfree);
|
|
if (sr != 0)
|
|
return sr;
|
|
spaced = 1;
|
|
} else {
|
|
schfs_space_reset(&v->space, v->total_blocks);
|
|
}
|
|
if (schfs_space_scan(v) != 0)
|
|
goto next;
|
|
v->free_blocks = v->space.free_blocks;
|
|
// A volume carrying ro_compat bits we do not implement stays
|
|
// read-only even though the map is now good: writing would leave
|
|
// whatever structure those bits describe stale, and stale is worse
|
|
// than absent because nothing would notice.
|
|
v->readonly = ro_forced;
|
|
}
|
|
// Which attributes are indexed, so attr.c can answer without a lookup
|
|
// on every write. A volume with no indexes -- every volume until
|
|
// somebody asks for one -- ends up with an empty list.
|
|
schfs_index_reload(v);
|
|
return 0;
|
|
|
|
next:
|
|
v->recovered++; // this candidate was newer, and unusable
|
|
continue;
|
|
}
|
|
|
|
// Nothing usable. Distinguish "not a SchFS volume" from "a SchFS volume
|
|
// that cannot be read", because the first is an ordinary answer to a probe
|
|
// and the second is a damaged disk somebody needs to hear about.
|
|
if (too_new)
|
|
return -3;
|
|
if (io_errors == SCHFS_UBER_COUNT)
|
|
return -2;
|
|
return seen ? -2 : -1;
|
|
}
|
|
|
|
int
|
|
schfs_vol_commit(struct schfs_vol *v)
|
|
{
|
|
struct schfs_uberblock *u = (struct schfs_uberblock *)v->ctx.a;
|
|
|
|
v->generation++;
|
|
v->uber_slot = (v->uber_slot + 1) % SCHFS_UBER_COUNT;
|
|
|
|
memset(u, 0, sizeof(*u));
|
|
u->magic = SCHFS_MAGIC;
|
|
u->version = SCHFS_VERSION;
|
|
u->blocksize = SCHFS_BLKSIZE;
|
|
u->generation = v->generation;
|
|
u->root = v->tree.root;
|
|
u->root_crc = v->tree.root_crc;
|
|
u->root_generation = v->tree.root_gen;
|
|
u->total_blocks = v->total_blocks;
|
|
// Blocks this group released count as free *as of this uberblock*: nothing
|
|
// the root below reaches touches them. They are only still marked in use in
|
|
// memory because the group being superseded is what a crash would land on
|
|
// until this very block is written.
|
|
u->free_blocks = v->space.nblocks
|
|
? v->space.free_blocks + v->space.pending
|
|
: v->free_blocks;
|
|
u->next_objectid = v->next_objectid;
|
|
u->root_objectid = v->root_objectid;
|
|
memmove(u->uuid, v->uuid, sizeof(u->uuid));
|
|
memmove(u->label, v->label, sizeof(u->label));
|
|
u->crc = uber_crc(u);
|
|
|
|
// The barrier the whole design rests on. Every block the new root reaches
|
|
// has been handed to bwrite by now, but "handed to bwrite" is not "on the
|
|
// medium": the kernel defers tree writes into the buffer cache, and the
|
|
// drive under it has a write cache of its own. Both are free to reorder.
|
|
//
|
|
// So: drain everything, *then* write the block that makes it live. A crash
|
|
// before this point leaves the previous uberblock newest and nothing is
|
|
// lost; a crash after it finds a tree that is fully present. Without the
|
|
// barrier there is a third outcome -- a newest uberblock naming a tree that
|
|
// never landed -- and it is unrecoverable, because the checksum on the
|
|
// uberblock is perfectly valid.
|
|
if (v->dev.bflush && v->dev.bflush(v->dev.ctx) != 0)
|
|
return -2;
|
|
|
|
if (v->dev.bwrite(v->dev.ctx, SCHFS_UBER_START + v->uber_slot, u) != 0)
|
|
return -2;
|
|
|
|
// And the uberblock itself, before this function claims the transaction is
|
|
// durable. Everything below acts on that claim: the blocks this group freed
|
|
// are handed back to the allocator, which is only safe once the group that
|
|
// still referenced them can no longer be what a crash comes back to.
|
|
if (v->dev.bflush && v->dev.bflush(v->dev.ctx) != 0)
|
|
return -2;
|
|
|
|
// Order matters here, and it is the whole crash-safety argument in three
|
|
// lines. Only now that the new uberblock is on the disk is the group this
|
|
// freed blocks in superseded, so only now may those blocks be handed out
|
|
// again; releasing them before the write would let a crash come back to a
|
|
// tree whose blocks had already been reused.
|
|
if (v->space.nblocks) {
|
|
schfs_space_commit(&v->space);
|
|
v->free_blocks = v->space.free_blocks;
|
|
}
|
|
|
|
// Everything written from here on belongs to the next transaction group, so
|
|
// the blocks just published become read-only to the tree again and the
|
|
// copy-on-write rule resumes. Advancing this is the whole reason a commit
|
|
// is a barrier rather than just another block write.
|
|
v->tree.generation = v->generation + 1;
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
schfs_vol_format(struct schfs_vol *v, struct schfs_bdev *dev,
|
|
uint64_t total_blocks, const char *label,
|
|
struct schfs_ctx *x, void *(*palloc)(void),
|
|
void (*pfree)(void *))
|
|
{
|
|
if (total_blocks <= SCHFS_FIRST_BLOCK)
|
|
return -3;
|
|
|
|
// Same reasoning as the release in schfs_vol_open: formatting over a
|
|
// volume that was previously open would otherwise drop its map on the
|
|
// floor, and the memset below is what makes it unrecoverable.
|
|
schfs_space_release(&v->space);
|
|
memset(v, 0, sizeof(*v));
|
|
v->dev = *dev;
|
|
v->ctx = *x;
|
|
v->tree.dev = &v->dev;
|
|
v->total_blocks = total_blocks;
|
|
v->root_objectid = SCHFS_ROOT_OBJECTID;
|
|
|
|
// Nothing is allocated on a fresh volume beyond the boot block and the
|
|
// ring, so the map starts empty rather than being scanned into existence.
|
|
{
|
|
int sr = schfs_space_init(&v->space, total_blocks, palloc, pfree);
|
|
if (sr != 0)
|
|
return sr;
|
|
}
|
|
v->free_blocks = v->space.free_blocks;
|
|
// The root's object id is consumed by the root itself, so allocation must
|
|
// start *past* it. Starting at SCHFS_FIRST_OBJECTID instead handed the
|
|
// first directory created the same id as the root: that directory then
|
|
// silently became the root, so it contained itself and any tree walk
|
|
// recursed until the stack ran out.
|
|
v->next_objectid = SCHFS_ROOT_OBJECTID + 1;
|
|
v->generation = 0; /* nothing committed yet */
|
|
v->tree.generation = 1; /* ...so this is the group being built */
|
|
v->uber_slot = -1;
|
|
v->readonly = 0;
|
|
|
|
if (label) {
|
|
unsigned i = 0;
|
|
for (; i < SCHFS_LABEL_MAX - 1 && label[i]; i++)
|
|
v->label[i] = label[i];
|
|
v->label[i] = '\0';
|
|
}
|
|
|
|
// A UUID from the block count and a fixed salt. Not random — mkfs is
|
|
// deliberately reproducible, so two runs over the same tree produce
|
|
// byte-identical images and a diff means a real change.
|
|
{
|
|
uint32_t a = schfs_crc32c(0, &total_blocks, sizeof(total_blocks));
|
|
uint32_t b = schfs_crc32c(a, v->label, SCHFS_LABEL_MAX);
|
|
for (int i = 0; i < 16; i++)
|
|
v->uuid[i] = (uint8_t)((i & 1 ? a : b) >> ((i % 4) * 8));
|
|
}
|
|
|
|
// Zero the whole ring so a stale uberblock from a previous filesystem on
|
|
// this device cannot outrank ours and be mounted instead.
|
|
memset(x->a, 0, SCHFS_BLKSIZE);
|
|
for (int i = 0; i < SCHFS_UBER_COUNT; i++)
|
|
if (v->dev.bwrite(v->dev.ctx, SCHFS_UBER_START + i, x->a) != 0)
|
|
return -2;
|
|
|
|
if (schfs_btree_create(&v->tree, &v->dev, x) != 0)
|
|
return -2;
|
|
|
|
return schfs_vol_commit(v);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Allocation */
|
|
/* */
|
|
/* Thin adapters over the space manager, so callers wiring up a */
|
|
/* schfs_bdev have something with the right shape to point at. The */
|
|
/* readonly check lives here rather than in space.c because "this */
|
|
/* volume may not be written" is a property of the mount, not of the */
|
|
/* bitmap. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
uint64_t
|
|
schfs_vol_alloc(struct schfs_vol *v)
|
|
{
|
|
if (v->readonly)
|
|
return 0;
|
|
return schfs_space_alloc(&v->space);
|
|
}
|
|
|
|
uint64_t
|
|
schfs_vol_alloc_run(struct schfs_vol *v, uint64_t n)
|
|
{
|
|
if (v->readonly)
|
|
return 0;
|
|
return schfs_space_alloc_run(&v->space, n);
|
|
}
|
|
|
|
void
|
|
schfs_vol_free(struct schfs_vol *v, uint64_t blk, uint64_t gen)
|
|
{
|
|
schfs_space_free(&v->space, blk, gen);
|
|
}
|