The last three phases of the filesystem, which were written together and
touch the same files throughout -- kmount.c, objw.c and vfs.c are each
rewritten by all three -- so they land as one commit rather than three
that could not be built separately.
Durability. A transaction that reaches the platter out of order is worse
than one that never reached it: the uberblock can name a tree whose
blocks are still in the drive's write cache. bdirty/bflush give the
buffer layer an explicit barrier, ideflush issues ATA FLUSH CACHE and
usb_storage_flush the SCSI SYNCHRONIZE CACHE that means the same thing,
and a commit now waits for the data before it writes the root that
points at it. The uberblock groups are read newest-first with fallback,
so a volume whose newest roots are torn mounts at an older one and says
which generations were lost.
Reflink. Extents carry a refcount, so two objects can name the same
blocks and neither frees them while the other holds them. Exposed as
FICLONE -- Linux's ioctl value, so `cp --reflink` works unmodified --
and as cmd/reflink for the case where there is no cp to hand.
Attributes, and the one genuinely new idea here. attr.c stores named
values on an object, reachable through setxattr/getxattr/removexattr at
Linux's syscall numbers. On top of that, schfs_index declares an index
over an attribute name and schfs_query answers "which objects carry this
value" from the B-tree rather than by walking. A query against an
unindexed attribute is refused with an error, never silently answered by
a full scan: a query that quietly becomes a scan is worse than one that
admits it cannot help. cmd/schattr drives both.
Three bugs fixed on the way, each found by the numbers being wrong
rather than by inspection:
- statfs's f_fsid was declared int64[2]. Linux's fsid_t is two *ints*,
so every field after it landed one slot late: userland read f_frsize
out of f_namelen, got 255, and df reported a 32 MB root filesystem
as 1992 KB. It hid for as long as it did because the fields used to
be invented constants, so wrong numbers looked like the usual ones.
- struct inode's nlink was a short against SchFS's uint32, so a file
past 32767 names came back negative -- and iput frees an object
whose count reaches zero. It is an int now, signed on purpose, so an
underflow lands somewhere a check can see rather than wrapping.
- filewrite split every write to fit v1's log. That limit belongs to
the journal, not to writing, and SchFS makes one transaction of
whatever it is handed: a 1 MB write was becoming 170 commits and
170 flushes for nothing. fs_ops carries max_write now, and SchFS
sets no limit.
Also here: a real mount table, so `mount -t schfs /dev/hdb /mnt` shows
up in /proc/mounts -- df looks a path up there before it calls statfs at
all, so a mount missing from that list was invisible to it -- several
volumes mounted at once, snapshots mounted by name, and /proc/fsstat
with an rdtsc-based counter, which is the only instrument fine enough to
see inside a single syscall.
Verified: `make schfs-check` passes in full, including the sweep that
crashes the volume at each of 119 points mid-transaction and requires
every one to mount with exactly its committed data, and the case where
both newest uberblocks are rubbish and the volume still comes back. The
headless boot check passes with 27 [ok] and no failures, on a SchFS root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
14 KiB
C
368 lines
14 KiB
C
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// 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;
|
|
}
|
|
|
|
static int
|
|
uber_valid(struct schfs_uberblock *u)
|
|
{
|
|
if (u->magic != SCHFS_MAGIC)
|
|
return 0;
|
|
if (u->version != SCHFS_VERSION)
|
|
return 0;
|
|
// A volume made with a different block size would otherwise be read with
|
|
// every offset wrong, which looks exactly like corruption.
|
|
if (u->blocksize != SCHFS_BLKSIZE)
|
|
return 0;
|
|
if (uber_crc(u) != u->crc)
|
|
return 0;
|
|
// 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 0;
|
|
return 1;
|
|
}
|
|
|
|
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))
|
|
{
|
|
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
|
|
|
|
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
|
|
}
|
|
if (!uber_valid(u))
|
|
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
|
|
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;
|
|
if (!uber_valid(u))
|
|
goto next; /* changed under us, or a flaky device */
|
|
|
|
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.
|
|
int sr = schfs_space_init(&v->space, v->total_blocks, palloc);
|
|
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;
|
|
v->readonly = 0;
|
|
}
|
|
// 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 (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))
|
|
{
|
|
if (total_blocks <= SCHFS_FIRST_BLOCK)
|
|
return -3;
|
|
|
|
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);
|
|
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)
|
|
{
|
|
schfs_space_free(&v->space, blk);
|
|
}
|