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:
parent
b336e4a566
commit
edc4e0537d
13 changed files with 572 additions and 144 deletions
|
|
@ -24,31 +24,54 @@
|
|||
// 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 a vector of pages because the kernel allocates a page at a time
|
||||
// and gives no promise that two are adjacent. That vector is fixed-size, and
|
||||
// its length is therefore the hard ceiling on how large a volume this build can
|
||||
// mount: SCHFS_SPACE_PAGES pages x SCHFS_SPACE_BITS_PER_PAGE blocks each.
|
||||
// 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_PAGES 512
|
||||
#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 * (uint64_t)SCHFS_SPACE_PAGES) /* 64 GiB */
|
||||
(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 {
|
||||
uint64_t *inuse[SCHFS_SPACE_PAGES]; // 1 = allocated
|
||||
uint64_t *pend [SCHFS_SPACE_PAGES]; // 1 = freed, but not yet reusable
|
||||
int npages;
|
||||
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.
|
||||
// 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 *);
|
||||
};
|
||||
|
||||
|
|
@ -107,6 +130,15 @@ struct schfs_vol {
|
|||
// 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;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -154,7 +186,7 @@ int schfs_snap_get(struct schfs_vol *v, struct schfs_cursor *c, uint64_t id,
|
|||
// 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);
|
||||
void schfs_vol_free(struct schfs_vol *v, uint64_t blk, uint64_t gen);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// space.c
|
||||
|
|
@ -193,10 +225,26 @@ 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);
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue