schfs: commit every 5 s, survive a full disk, mount up to 64 TiB

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
This commit is contained in:
Nexoniarz 2026-10-23 12:20:05 +02:00
commit edc4e0537d
13 changed files with 572 additions and 144 deletions

View file

@ -46,39 +46,84 @@
/* ------------------------------------------------------------------ */
/* Bit vectors, paged */
/* */
/* The bitmap has to be one flat array to be indexable, and the kernel */
/* hands out memory a page at a time with no promise that two pages */
/* are adjacent. So it is a vector of page pointers instead: one page */
/* covers SCHFS_SPACE_BITS_PER_PAGE blocks, and the page vector itself */
/* is a fixed-size array, which is what puts a hard ceiling on volume */
/* size. See docs/architecture/schfs.md for the resulting limits. */
/* The bitmap has to be indexable, and the kernel hands out memory a */
/* page at a time with no promise that two pages are adjacent. So it */
/* is pages of bits found through pages of pointers -- see struct */
/* schfs_bits -- and a missing page reads as zeros. See */
/* docs/architecture/schfs.md for the resulting limits. */
/* ------------------------------------------------------------------ */
static int
bit_test(uint64_t *const *pages, uint64_t b)
static uint64_t *
bits_page(const struct schfs_bits *v, uint64_t pg)
{
uint64_t pg = b / SCHFS_SPACE_BITS_PER_PAGE;
uint64_t off = b % SCHFS_SPACE_BITS_PER_PAGE;
return (int)((pages[pg][off / 64] >> (off % 64)) & 1);
uint64_t **d = v->dir[pg / SCHFS_SPACE_PTRS_PER_PAGE];
return d ? d[pg % SCHFS_SPACE_PTRS_PER_PAGE] : 0;
}
static void
bit_set(uint64_t *const *pages, uint64_t b)
// The page for `pg`, taken from `palloc` (zeroed) if it is not there yet.
// 0 when there is no memory for it.
static uint64_t *
bits_page_get(struct schfs_bits *v, uint64_t pg, void *(*palloc)(void))
{
uint64_t pg = b / SCHFS_SPACE_BITS_PER_PAGE;
uint64_t ***d = &v->dir[pg / SCHFS_SPACE_PTRS_PER_PAGE];
if (!*d) {
if (!palloc || !(*d = (uint64_t **)palloc()))
return 0;
memset(*d, 0, SCHFS_BLKSIZE);
}
uint64_t **slot = &(*d)[pg % SCHFS_SPACE_PTRS_PER_PAGE];
if (!*slot) {
if (!(*slot = (uint64_t *)palloc()))
return 0;
memset(*slot, 0, SCHFS_BLKSIZE);
}
return *slot;
}
// Give every page back, pointer pages included.
static void
bits_free(struct schfs_bits *v, void (*pfree)(void *))
{
for (int i = 0; i < SCHFS_SPACE_DIRS; i++) {
if (!v->dir[i])
continue;
if (pfree) {
for (int j = 0; j < (int)SCHFS_SPACE_PTRS_PER_PAGE; j++)
if (v->dir[i][j])
pfree(v->dir[i][j]);
pfree(v->dir[i]);
}
v->dir[i] = 0;
}
}
static int
bit_test(const struct schfs_bits *v, uint64_t b)
{
uint64_t *pg = bits_page(v, b / SCHFS_SPACE_BITS_PER_PAGE);
uint64_t off = b % SCHFS_SPACE_BITS_PER_PAGE;
pages[pg][off / 64] |= 1ULL << (off % 64);
return pg ? (int)((pg[off / 64] >> (off % 64)) & 1) : 0;
}
// Only on a page that exists: the inuse map has all of its pages from init,
// and the pend map takes one in schfs_space_free before it sets anything.
static void
bit_set(const struct schfs_bits *v, uint64_t b)
{
uint64_t *pg = bits_page(v, b / SCHFS_SPACE_BITS_PER_PAGE);
uint64_t off = b % SCHFS_SPACE_BITS_PER_PAGE;
pg[off / 64] |= 1ULL << (off % 64);
}
// The whole 64-block word containing b, so a scan can skip a fully used region
// sixty-four blocks at a time instead of one. On a nearly full volume that is
// the difference between an allocation costing microseconds and milliseconds.
static uint64_t
bit_word(uint64_t *const *pages, uint64_t b)
bit_word(const struct schfs_bits *v, uint64_t b)
{
uint64_t pg = b / SCHFS_SPACE_BITS_PER_PAGE;
uint64_t *pg = bits_page(v, b / SCHFS_SPACE_BITS_PER_PAGE);
uint64_t off = b % SCHFS_SPACE_BITS_PER_PAGE;
return pages[pg][off / 64];
return pg ? pg[off / 64] : 0;
}
static int
@ -101,40 +146,44 @@ popcount64(uint64_t v)
void
schfs_space_reset(struct schfs_space *s, uint64_t nblocks)
{
for (int i = 0; i < s->npages; i++) {
memset(s->inuse[i], 0, SCHFS_BLKSIZE);
memset(s->pend[i], 0, SCHFS_BLKSIZE);
}
for (uint64_t i = 0; i < s->npages; i++)
memset(bits_page(&s->inuse, i), 0, SCHFS_BLKSIZE);
bits_free(&s->pend, s->pfree);
s->nblocks = nblocks;
s->free_blocks = nblocks - SCHFS_FIRST_BLOCK;
s->pending = 0;
// 1% of the volume, at least 1 MB and at most 64 MB. A tree update touches
// a path of at most SCHFS_MAX_LEVEL nodes per record, and a group rewrites
// a node once however often it changes, so this is generous; btrfs keeps
// between 16 MB and 512 MB for the same job.
s->reserve = nblocks / 100;
if (s->reserve < 256)
s->reserve = 256;
if (s->reserve > 16384)
s->reserve = 16384;
s->hint = SCHFS_FIRST_BLOCK;
// Cleared, not preserved: schfs_space_scan sets RETAIN_ALL when it finds a
// snapshot, so carrying one candidate root's answer into the next root's
// scan would pin blocks for a snapshot that tree may not even have.
s->flags = 0;
s->snap_gen = 0;
// The boot block and the uberblock ring are never allocatable, and neither
// is anything past the end of the volume: the last bitmap page covers more
// blocks than exist, and a stray allocation out there would be written to a
// sector the device does not have.
for (uint64_t b = 0; b < SCHFS_FIRST_BLOCK; b++)
bit_set(s->inuse, b);
for (uint64_t b = nblocks;
b < (uint64_t)s->npages * SCHFS_SPACE_BITS_PER_PAGE; b++)
bit_set(s->inuse, b);
bit_set(&s->inuse, b);
for (uint64_t b = nblocks; b < s->npages * SCHFS_SPACE_BITS_PER_PAGE; b++)
bit_set(&s->inuse, b);
}
void
schfs_space_release(struct schfs_space *s)
{
if (s->pfree) {
for (int i = 0; i < s->npages; i++) {
if (s->inuse[i]) s->pfree(s->inuse[i]);
if (s->pend[i]) s->pfree(s->pend[i]);
}
}
bits_free(&s->inuse, s->pfree);
bits_free(&s->pend, s->pfree);
// Zeroed rather than merely emptied, so calling this twice is harmless and
// a released map cannot be mistaken for a sized one.
memset(s, 0, sizeof(*s));
@ -145,7 +194,8 @@ schfs_space_init(struct schfs_space *s, uint64_t nblocks,
void *(*palloc)(void), void (*pfree)(void *))
{
memset(s, 0, sizeof(*s));
s->pfree = pfree;
s->palloc = palloc;
s->pfree = pfree;
if (nblocks <= SCHFS_FIRST_BLOCK)
return -3;
@ -155,27 +205,14 @@ schfs_space_init(struct schfs_space *s, uint64_t nblocks,
uint64_t npages = (nblocks + SCHFS_SPACE_BITS_PER_PAGE - 1)
/ SCHFS_SPACE_BITS_PER_PAGE;
// Every inuse page now; pend pages as frees need them. A failure part way
// leaves what was taken reachable from the map, so release finds it.
for (uint64_t i = 0; i < npages; i++) {
// npages is raised as soon as inuse[i] is ours, not after both pages
// are. It used to be raised after, which meant a failure on the second
// allocation left the first one owned by nobody: outside the count, so
// unreachable to any cleanup, and never freed. The comment here said
// "caller frees what was taken" and there was nothing the caller could
// call.
void *p = palloc();
if (p == 0) {
if (!bits_page_get(&s->inuse, i, palloc)) {
schfs_space_release(s);
return -5;
}
s->inuse[i] = (uint64_t *)p;
s->npages = (int)(i + 1);
p = palloc();
if (p == 0) {
schfs_space_release(s);
return -5;
}
s->pend[i] = (uint64_t *)p;
s->npages = i + 1;
}
schfs_space_reset(s, nblocks);
@ -189,9 +226,9 @@ schfs_space_mark(struct schfs_space *s, uint64_t blk, uint64_t n)
uint64_t b = blk + i;
if (b < SCHFS_FIRST_BLOCK || b >= s->nblocks)
continue;
if (bit_test(s->inuse, b))
if (bit_test(&s->inuse, b))
continue; /* already counted; extents may be shared */
bit_set(s->inuse, b);
bit_set(&s->inuse, b);
s->free_blocks--;
}
}
@ -216,12 +253,12 @@ schfs_space_alloc_run(struct schfs_space *s, uint64_t n)
uint64_t run = 0, first = 0;
for (uint64_t b = lo; b < hi; ) {
if (b % 64 == 0 && bit_word(s->inuse, b) == ~0ULL) {
if (b % 64 == 0 && bit_word(&s->inuse, b) == ~0ULL) {
run = 0;
b += 64;
continue;
}
if (bit_test(s->inuse, b)) {
if (bit_test(&s->inuse, b)) {
run = 0;
b++;
continue;
@ -232,7 +269,7 @@ schfs_space_alloc_run(struct schfs_space *s, uint64_t n)
b++;
if (run == n) {
for (uint64_t i = 0; i < n; i++)
bit_set(s->inuse, first + i);
bit_set(&s->inuse, first + i);
s->free_blocks -= n;
s->hint = first + n < s->nblocks ? first + n : SCHFS_FIRST_BLOCK;
return first;
@ -249,32 +286,37 @@ schfs_space_alloc(struct schfs_space *s)
}
void
schfs_space_free(struct schfs_space *s, uint64_t blk)
schfs_space_free(struct schfs_space *s, uint64_t blk, uint64_t gen)
{
// A snapshot exists, so this block may still be reachable from it.
// A snapshot may still be holding this block, and if so it must stay.
//
// Copy-on-write frees the old block whenever one is rewritten, and that is
// exactly the block a snapshot is holding. Freeing it would hand it out
// again and overwrite the data the snapshot exists to keep -- silently,
// with the damage only visible later when the snapshot is read.
//
// Knowing *which* freed blocks a snapshot still needs requires reference
// counting per extent, which is a real design and not a flag. Until that
// exists, the safe answer is to free nothing while a snapshot is present:
// the free map is derived at mount anyway, so everything genuinely
// unreachable is reclaimed on the next mount rather than lost. Conservative
// in the only direction that is safe -- it can under-report free space, and
// can never hand out a block that is still in use.
if (s->flags & SCHFS_SPACE_RETAIN_ALL)
// ZFS's rule says which: a snapshot names the tree committed before the
// group it was created in, so it can hold only blocks written before that
// group. A block born in or after the newest snapshot's group is in none
// of them and goes back to the free map as usual; only the older ones are
// kept, until a mount derives the map from what the snapshots still reach.
// This used to keep everything while any snapshot existed, so a volume
// with one never reused a block it freed until it was remounted.
if ((s->flags & SCHFS_SPACE_RETAIN_ALL) && gen < s->snap_gen)
return;
if (blk < SCHFS_FIRST_BLOCK || blk >= s->nblocks)
return;
if (!bit_test(s->inuse, blk))
if (!bit_test(&s->inuse, blk))
return; /* never allocated, or freed twice */
if (bit_test(s->pend, blk))
if (bit_test(&s->pend, blk))
return; /* already queued this group */
bit_set(s->pend, blk);
// No page to note it in, and no memory for one: the block stays in use
// until the next mount derives the map again. Lost space for a while,
// never a block handed out twice.
if (!bits_page_get(&s->pend, blk / SCHFS_SPACE_BITS_PER_PAGE, s->palloc))
return;
bit_set(&s->pend, blk);
s->pending++;
}
@ -282,9 +324,11 @@ void
schfs_space_commit(struct schfs_space *s)
{
uint64_t words = SCHFS_SPACE_BITS_PER_PAGE / 64;
for (int p = 0; p < s->npages; p++) {
uint64_t *pend = s->pend[p];
uint64_t *inuse = s->inuse[p];
for (uint64_t p = 0; p < s->npages; p++) {
uint64_t *pend = bits_page(&s->pend, p);
uint64_t *inuse = bits_page(&s->inuse, p);
if (!pend)
continue;
for (uint64_t w = 0; w < words; w++) {
if (pend[w] == 0)
continue;
@ -293,6 +337,8 @@ schfs_space_commit(struct schfs_space *s)
pend[w] = 0;
}
}
// The pages stay for the next group: a volume being written frees in the
// same places again, and taking and dropping them every commit is waste.
s->pending = 0;
}
@ -341,6 +387,29 @@ mark_leaf_extents(struct schfs_space *s, const void *blk)
}
}
// Inodes with no links left, for the mount to free. See schfs_vol.orphans.
static void
collect_orphans(struct schfs_vol *v, const void *blk)
{
const struct schfs_item *it = SCHFS_ITEMS(blk);
int n = SCHFS_HDR(blk)->nritems;
for (int i = 0; i < n; i++) {
if (it[i].key.type != SCHFS_KEY_INODE)
continue;
if (it[i].size != sizeof(struct schfs_inode))
continue;
if ((int)it[i].offset + (int)it[i].size > SCHFS_BLKSIZE)
continue;
const struct schfs_inode *in =
(const struct schfs_inode *)((const char *)blk + it[i].offset);
if (in->nlink != 0 || it[i].key.objectid == v->root_objectid)
continue;
if (v->norphans >= SCHFS_ORPHANS_MAX) { v->orphans_more = 1; continue; }
v->orphans[v->norphans++] = it[i].key.objectid;
}
}
// Snapshot roots found while walking, so they can be walked in turn.
//
// Collected rather than descended into immediately: the walk keeps one block
@ -351,6 +420,7 @@ mark_leaf_extents(struct schfs_space *s, const void *blk)
struct snap_roots {
int n;
int overflow;
uint64_t newest; /* the largest `created` seen */
uint64_t nr[SNAP_ROOTS_MAX];
uint32_t crc[SNAP_ROOTS_MAX];
};
@ -370,6 +440,8 @@ collect_snap_roots(struct snap_roots *sr, const void *blk)
continue;
const struct schfs_snap *sn =
(const struct schfs_snap *)((const char *)blk + it[i].offset);
if (sn->created > sr->newest)
sr->newest = sn->created;
if (sr->n >= SNAP_ROOTS_MAX) { sr->overflow = 1; continue; }
sr->nr[sr->n] = sn->root;
sr->crc[sr->n] = sn->root_crc;
@ -411,8 +483,10 @@ scan_root(struct schfs_vol *v, uint64_t root, uint32_t root_crc,
if (SCHFS_HDR(blk)->level == 0) {
mark_leaf_extents(s, blk);
if (sr)
if (sr) {
collect_snap_roots(sr, blk);
collect_orphans(v, blk);
}
d--;
} else if (path[d].idx < SCHFS_HDR(blk)->nritems) {
struct schfs_kptr *kp = &SCHFS_KPTRS(blk)[path[d].idx];
@ -452,13 +526,18 @@ schfs_space_scan(struct schfs_vol *v)
struct snap_roots sr;
sr.n = 0;
sr.overflow = 0;
sr.newest = 0;
v->norphans = 0;
v->orphans_more = 0;
int r = scan_root(v, v->tree.root, v->tree.root_crc, &sr);
if (r != 0)
return r;
if (sr.n > 0)
v->space.flags |= SCHFS_SPACE_RETAIN_ALL;
if (sr.n > 0 || sr.overflow) {
v->space.flags |= SCHFS_SPACE_RETAIN_ALL;
v->space.snap_gen = sr.newest;
}
for (int i = 0; i < sr.n; i++) {
r = scan_root(v, sr.nr[i], sr.crc[i], 0);