SchOS/kernel/fs/schfs/schfs_int.h
Nexoniarz edc4e0537d 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
2026-10-23 12:20:05 +02:00

439 lines
21 KiB
C

// SPDX-License-Identifier: GPL-2.0-or-later
//
// SchFS internals: the volume (uberblock ring, block allocation) and the object
// layer (inodes, directories, file data) that sit on top of the B-tree.
//
// Split three ways so the kernel links only what it needs:
//
// vol.c open / format / commit — the uberblock ring
// obj.c read paths — kernel needs these in phase 2
// objw.c write paths — host tools only, until phase 3
//
// Everything here is portable C compiled both hosted (mkfs, fsck) and freestanding
// (kernel), so a bug is found on the host, where a test takes a second rather
// than a boot.
#ifndef SCHFS_INT_H
#define SCHFS_INT_H
#include "btree.h"
// ---------------------------------------------------------------------------
// space.c — the allocation bitmap
//
// One bit per block, held in RAM and derived from the committed tree at mount
// rather than stored on disk. See space.c for why.
//
// The bitmap is paged because the kernel allocates a page at a time and gives
// no promise that two are adjacent, and the pages are found through a second
// level of pages holding pointers, the way a page table finds them. A page of
// bits covers 128 MiB of volume and a page of pointers 64 GiB of it, so the
// fixed top level of SCHFS_SPACE_DIRS entries is what sets the ceiling.
//
// It was one level, 512 pointers held in the struct, which made 64 GiB the
// largest volume this kernel could mount: most laptop disks were refused.
// ---------------------------------------------------------------------------
#define SCHFS_SPACE_BITS_PER_PAGE ((uint64_t)SCHFS_BLKSIZE * 8) /* 32768 */
#define SCHFS_SPACE_PTRS_PER_PAGE (SCHFS_BLKSIZE / 8) /* 512 */
#define SCHFS_SPACE_DIRS 1024
#define SCHFS_SPACE_PAGES \
((uint64_t)SCHFS_SPACE_DIRS * SCHFS_SPACE_PTRS_PER_PAGE)
#define SCHFS_MAX_BLOCKS \
(SCHFS_SPACE_BITS_PER_PAGE * SCHFS_SPACE_PAGES) /* 64 TiB */
// A paged bit vector: dir[i][j] is the page of bits for page number
// i * SCHFS_SPACE_PTRS_PER_PAGE + j. A null page is all zeros.
struct schfs_bits {
uint64_t **dir[SCHFS_SPACE_DIRS];
};
struct schfs_space {
struct schfs_bits inuse; // 1 = allocated
// 1 = freed, but not yet reusable. Only the pages that have seen a free
// since the last commit exist, so the map costs one bit per block rather
// than two: 32 MiB for a 1 TiB volume, not 64.
struct schfs_bits pend;
uint64_t npages;
uint64_t nblocks;
uint64_t free_blocks;
uint64_t pending; // bits set in pend[], counted
// Blocks only metadata may take: see schfs_space_low().
uint64_t reserve;
uint64_t hint; // rotating search start
// SCHFS_SPACE_RETAIN_ALL while a snapshot exists -- see schfs_space_free.
unsigned flags;
// The newest snapshot's `created` group. A block written in that group or
// later is in no snapshot.
uint64_t snap_gen;
// How to give the pages back. Carried here rather than passed to the
// release call so that a map can only ever be freed by the allocator that
// filled it: the kernel's pages come from kalloc and a host tool's from
// calloc, and the two are not interchangeable. `palloc` is kept as well,
// for the pend pages taken as frees arrive.
void *(*palloc)(void);
void (*pfree)(void *);
};
// A mounted (or under-construction) volume.
//
// MUST BE ZEROED before its first use. schfs_vol_open and schfs_vol_format both
// release any allocation map the volume is already holding before building a
// new one -- that is what stops a re-open leaking the old map -- and on a first
// use they are reading whatever the struct was born with. Every volume in the
// tree is static storage, so this costs nothing; a stack-allocated one would
// have to be memset first.
//
// NOT copyable: tree.dev points at this struct's own dev field, so a copy
// leaves the tree talking to whichever struct it was built in. Open into the
// storage it will live in.
struct schfs_vol {
struct schfs_bdev dev;
struct schfs_btree tree;
// The three block buffers every mutation borrows, handed in at open or
// format and owned by the caller for the life of the volume. Nothing in
// SchFS may put a SCHFS_BLKSIZE buffer on the stack: a block is 4096 bytes
// and so is the whole kernel stack.
//
// The object layer also uses ctx.a as a cursor buffer, which is safe on the
// one condition that no cursor is still live when a tree mutation runs.
// Every write path here reads what it needs out of the cursor first.
struct schfs_ctx ctx;
struct schfs_space space;
uint64_t total_blocks;
uint64_t free_blocks;
uint64_t next_objectid;
uint64_t root_objectid;
uint64_t generation; // last *committed* transaction group
int uber_slot; // ring slot the last commit used
int readonly;
// How many newer uberblocks schfs_vol_open had to reject before it found a
// root it could actually read. Zero on every healthy mount. Anything else
// means transactions were lost, which is a recovery the operator has to be
// told about rather than something to absorb quietly.
int recovered;
char label[SCHFS_LABEL_MAX];
uint8_t uuid[16];
// Staging for a directory's packed dirent list. Deliberately not one of the
// ctx buffers: a split reuses ctx.b and ctx.c while the value being
// inserted is still being read from, so the value needs storage of its own.
char dirbuf[SCHFS_MAX_INLINE];
// Which attributes are indexed, as a packed list of (length, name), read
// off the disk at mount. Cached here rather than looked up because every
// attribute write asks the question and the answer is almost always no --
// a tree descent to say so would make every write pay for a feature it is
// not using. See kernel/fs/schfs/attr.c.
char idxnames[512];
unsigned idxnames_len;
// Objects the mount found with no names left: unlinked while open, and
// the machine went down before the last close freed them. Collected by
// the space scan, freed by whoever mounted the volume, as ext4 does with
// its orphan list. More than fit are found again by the next mount.
#define SCHFS_ORPHANS_MAX 32
uint64_t orphans[SCHFS_ORPHANS_MAX];
int norphans;
int orphans_more;
};
// ---------------------------------------------------------------------------
// vol.c
// ---------------------------------------------------------------------------
// Scan the whole uberblock ring and adopt the highest generation that both
// checksums and points at a readable root. This *is* the crash recovery story:
// there is no journal to replay, because a torn update simply leaves an older
// slot as the newest valid one. Returns 0, -1 if no valid uberblock exists
// (not a SchFS volume), -2 on I/O error.
//
// `x` supplies the volume's three scratch blocks; they are kept for its whole
// lifetime, so they must outlive it and must not alias one another. `palloc`
// yields pages for the allocation bitmap and may be NULL for a read-only mount,
// which then cannot allocate at all.
int schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
struct schfs_ctx *x, void *(*palloc)(void),
void (*pfree)(void *));
// Lay down an empty volume: one empty tree and one uberblock.
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 *));
// Publish the current tree by writing the next slot in the ring. Until this
// returns, everything written since the last commit is invisible and a crash
// discards it — which is exactly what makes the format crash-safe.
int schfs_vol_commit(struct schfs_vol *v);
// Snapshots. Create records the *committed* root; the caller commits after.
// Lookup fills `out` for a given id. Deleting forgets the record, and the next
// derivation stops marking what only it reached.
int schfs_snap_create(struct schfs_vol *v, uint64_t id, const char *name,
unsigned namelen);
int schfs_snap_delete(struct schfs_vol *v, uint64_t id);
int schfs_snap_get(struct schfs_vol *v, struct schfs_cursor *c, uint64_t id,
struct schfs_snap *out);
// Block allocation. Returns 0 when the volume is full.
//
// Not themselves schfs_bdev callbacks: a bdev has one ctx shared by all of its
// hooks, and the caller's block I/O needs that slot for its own state (a file
// handle, a device number). Callers wrap these in a one-line adapter instead.
uint64_t schfs_vol_alloc(struct schfs_vol *v);
uint64_t schfs_vol_alloc_run(struct schfs_vol *v, uint64_t n);
void schfs_vol_free(struct schfs_vol *v, uint64_t blk, uint64_t gen);
// ---------------------------------------------------------------------------
// space.c
// ---------------------------------------------------------------------------
// Build an empty map covering `nblocks`, with everything below
// SCHFS_FIRST_BLOCK already reserved. `palloc` yields SCHFS_BLKSIZE-sized
// pages; the map keeps them for the volume's lifetime. Returns 0, -3 if the
// volume is too small to hold anything, -4 if it is larger than this build can
// map (see SCHFS_MAX_BLOCKS), -5 if palloc ran out.
int schfs_space_init(struct schfs_space *s, uint64_t nblocks,
void *(*palloc)(void), void (*pfree)(void *));
// Hand every page back and zero the map. Safe on a map that was never
// initialised and safe to call twice, so an error path need not work out how
// far initialisation got.
//
// This exists because there was no way to give the pages back at all. The
// kernel leaked the whole map on every unmount -- up to 2 MB a time -- and
// schfs_objtest leaked one map per volume it opened, which ran AddressSanitizer
// up to 2 MB across 518 allocations and made the whole SchFS suite exit
// non-zero. A real leak in the filesystem would have arrived as noise inside
// that.
void schfs_space_release(struct schfs_space *s);
// Return an already-sized map to "empty volume", reusing its pages. Mounting
// may try several uberblocks, and each candidate root needs its scan to start
// from a clean map -- but the pages were taken once and must not be taken
// again, which is the one thing schfs_space_init cannot do twice.
void schfs_space_reset(struct schfs_space *s, uint64_t nblocks);
// Record a run as allocated. Idempotent, because an extent range may be shared
// by several files.
void schfs_space_mark(struct schfs_space *s, uint64_t blk, uint64_t n);
uint64_t schfs_space_alloc(struct schfs_space *s);
uint64_t schfs_space_alloc_run(struct schfs_space *s, uint64_t n);
// The metadata reserve, as btrfs and ZFS keep one.
//
// A copy-on-write tree needs new blocks to change anything at all, deleting
// included, and an update that runs out halfway leaves a half-built tree the
// next commit publishes. That is what a full disk used to do: the root volume
// would not mount again. So file data and new names stop at the reserve and
// report ENOSPC there, while a tree update in progress, and rm, may use it.
//
// `div` 1 is the data threshold; 2 is the one for creating names, which use
// far less, so a full disk can still be tidied by `mv` and `touch`.
static inline int
schfs_space_low(const struct schfs_space *s, unsigned div, uint64_t want)
{
return s->nblocks && s->free_blocks < s->reserve / div + want;
}
// Release a block — but only for the *next* transaction group. Until the
// commit that supersedes it, the last committed tree still points here and a
// crash would land on it.
void schfs_space_free(struct schfs_space *s, uint64_t blk, uint64_t gen);
// Fold this group's deferred frees back into the free map. Called by
// schfs_vol_commit once the new uberblock is on the disk, never before.
void schfs_space_commit(struct schfs_space *s);
// Walk the committed tree and mark every block it reaches. This is what makes
// the map correct without storing it. Returns 0 or -2 on an unreadable tree.
int schfs_space_scan(struct schfs_vol *v);
// ---------------------------------------------------------------------------
// obj.c — read side
// ---------------------------------------------------------------------------
uint64_t schfs_namehash(const char *name, unsigned len);
// Test-only knob; see obj.c. Narrowing this forces directory-hash collisions
// so the packed-dirent path actually runs. Never changed outside tools/.
extern uint64_t schfs_hash_mask;
void schfs_mkkey(struct schfs_key *k, uint64_t objectid, uint8_t type,
uint64_t offset);
// Fetch an inode record. Returns 0, -1 if there is no such object, -2 corrupt.
int schfs_inode_get(struct schfs_vol *v, struct schfs_cursor *c,
uint64_t ino, struct schfs_inode *out);
// Resolve one name inside a directory. Returns 0 and fills *out_ino/*out_type,
// -1 if absent, -2 corrupt.
int schfs_dir_lookup(struct schfs_vol *v, struct schfs_cursor *c,
uint64_t dir, const char *name, unsigned namelen,
uint64_t *out_ino, uint16_t *out_type);
// Iterate a directory's real entries in hash order. "." and ".." are not
// stored and are not reported; callers that need them synthesise them.
// A non-zero return from fn stops the walk and is passed back.
// A directory entry, with the tree key's hash it was found under. The hash is
// what a partial listing resumes from -- see schfs_dir_iterate_from.
typedef int (*schfs_dir_fn)(uint64_t ino, uint16_t type, const char *name,
unsigned namelen, uint64_t hash, void *arg);
int schfs_dir_iterate_from(struct schfs_vol *v, struct schfs_cursor *c,
uint64_t dir, uint64_t start_hash,
schfs_dir_fn fn, void *arg);
int schfs_dir_iterate(struct schfs_vol *v, struct schfs_cursor *c, uint64_t dir,
int (*fn)(uint64_t ino, uint16_t type, const char *name,
unsigned namelen, void *arg),
void *arg);
// Read from a file. Short reads at end-of-file are normal; returns the byte
// count, or -1 on a missing object, -2 on corruption or a failed data checksum.
// `dblk` is a caller-owned SCHFS_BLKSIZE scratch buffer for data blocks.
/* Drops the one-entry inode and extent read caches in obj.c. Every write
* entry point calls this before touching anything: the caches are only safe
* because invalidation is blunt, and one that is merely usually right hands
* back another file's data. */
void schfs_cache_invalidate(void);
long schfs_file_read(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
uint64_t off, void *buf, unsigned n, void *dblk);
// Walk an absolute path. Returns the object id, or 0 if any component is
// missing or is not a directory.
uint64_t schfs_path_lookup(struct schfs_vol *v, struct schfs_cursor *c,
const char *path);
// ---------------------------------------------------------------------------
// objw.c — write side
//
// Shared error codes throughout: 0 success, -1 absent or not permitted,
// -2 corruption or I/O failure, -3 out of space, -4 a directory hash-collision
// list that will not fit in one item, -5 directory not empty.
//
// None of it is visible to a reader until schfs_vol_commit publishes the tree,
// which is what lets a failure part way through a multi-record operation be
// abandoned rather than unwound.
// ---------------------------------------------------------------------------
uint64_t schfs_alloc_objectid(struct schfs_vol *v);
int schfs_inode_put(struct schfs_vol *v, uint64_t ino,
const struct schfs_inode *in);
// Add one entry, appending to the packed list if another name already hashes
// the same. -1 if the name is already present.
int schfs_dir_add(struct schfs_vol *v, uint64_t dir, const char *name,
unsigned namelen, uint64_t child, uint16_t type);
// Remove one entry. -1 if there is no such name.
int schfs_dir_del(struct schfs_vol *v, uint64_t dir, const char *name,
unsigned namelen);
// 1 if the directory holds no entries, 0 if it holds some, -2 on corruption.
int schfs_dir_empty(struct schfs_vol *v, uint64_t dir);
// Claim a contiguous run of data blocks for a file whose final size is already
// known, and record the whole run as ONE extent. Worth calling before a bulk
// write: without it, data and tree blocks interleave, and only the merging in
// schfs_file_write keeps the extent count down.
int schfs_file_reserve(struct schfs_vol *v, uint64_t ino, uint64_t nblocks);
// Share `src`'s data blocks with `dst` without copying them. Both remain
// ordinary writable files; the first write to either copies only the run it
// touches. Costs one metadata record per run, whatever the file's size.
int schfs_file_reflink(struct schfs_vol *v, uint64_t src, uint64_t dst);
// ---------------------------------------------------------------------------
// attr.c -- attributes, the index over them, and the query that uses it
// ---------------------------------------------------------------------------
// One attribute. Returns 1 and fills the buffers if present, 0 if absent, -3 if
// `out` is too small (the length is still reported), -2 on corruption.
int schfs_attr_get(struct schfs_vol *v, uint64_t ino, const char *name,
unsigned namelen, void *out, unsigned cap,
unsigned *valuelen, uint16_t *type);
// Every attribute on an object. The callback returns non-zero to stop.
int schfs_attr_iterate(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
int (*fn)(const char *name, unsigned namelen,
const void *val, unsigned vallen,
uint16_t type, void *arg),
void *arg);
// Set or replace an attribute, keeping any index in step.
int schfs_attr_set(struct schfs_vol *v, uint64_t ino, const char *name,
unsigned namelen, const void *val, unsigned vallen,
uint16_t type);
// Remove one. Absent is not a failure.
int schfs_attr_del(struct schfs_vol *v, uint64_t ino, const char *name,
unsigned namelen);
// Start indexing an attribute. Affects values written after this only.
int schfs_index_create(struct schfs_vol *v, const char *name, unsigned namelen);
// Load the indexed-attribute list from the tree into the volume. Called at
// mount, and after the list changes.
int schfs_index_reload(struct schfs_vol *v);
// Every object whose `name` attribute equals `val`. One tree descent, whatever
// the size of the filesystem. Returns how many ids were written, or negative.
int schfs_query_equal(struct schfs_vol *v, const char *name, unsigned namelen,
const void *val, unsigned vallen, uint64_t *out, int cap);
// Write at an arbitrary offset, allocating blocks, extent records and data
// checksums, copying any block that a committed tree can still reach, and
// splitting the extent record around it. Writing past the end extends the file;
// a gap left behind stays a hole and reads back as zeros. Returns the byte
// count written, or a negative error.
long schfs_file_write(struct schfs_vol *v, uint64_t ino, uint64_t off,
const void *buf, unsigned n, void *dblk);
// Append at the current end of file. A convenience over schfs_file_write, kept
// because image builders write nothing else.
int schfs_file_append(struct schfs_vol *v, uint64_t ino, const void *buf,
unsigned n, void *dblk);
// Set a file's length, releasing whole blocks past the new end and zeroing what
// is left of the block the new end falls inside — truncating down and growing
// again has to read back zeros. Growing alone costs nothing: a range with no
// extent records is a hole. `dblk` is a caller-owned SCHFS_BLKSIZE buffer.
int schfs_file_truncate(struct schfs_vol *v, uint64_t ino, uint64_t size,
void *dblk);
// Release an object's data, checksums and inode record. The caller must already
// have removed every name pointing at it.
int schfs_object_free(struct schfs_vol *v, uint64_t ino);
// Create an object and the entry naming it. `now` is the timestamp to stamp all
// three times with — SchFS has no clock of its own.
int schfs_create(struct schfs_vol *v, uint64_t dir, const char *name,
unsigned namelen, uint16_t type, uint32_t mode,
uint16_t rdev_major, uint16_t rdev_minor, uint64_t now,
uint64_t *out_ino);
// A second name for an existing object. -1 for a directory: hard-linking one
// makes cycles possible, and a cycle turns every recursive walk infinite.
int schfs_link(struct schfs_vol *v, uint64_t dir, const char *name,
unsigned namelen, uint64_t ino);
// Remove a name, and the object with it once the last name is gone. Handles
// directories too, refusing with -5 if the directory still has entries.
int schfs_unlink(struct schfs_vol *v, uint64_t dir, const char *name,
unsigned namelen);
// Move a name, replacing the target if one exists. Refuses to move a directory
// into its own subtree, which would detach that subtree from the root.
int schfs_rename(struct schfs_vol *v, uint64_t olddir, const char *oldname,
unsigned oldlen, uint64_t newdir, const char *newname,
unsigned newlen);
#endif /* SCHFS_INT_H */