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
|
|
@ -17,16 +17,26 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// Transactions
|
||||
//
|
||||
// Every entry point below that changes anything is one transaction group: it
|
||||
// takes the volume lock, does its work, and commits before returning. That
|
||||
// makes an operation durable the moment it returns, and — because a commit is a
|
||||
// single uberblock write that either lands or does not — indivisible against a
|
||||
// crash. There is no journal to replay and nothing to undo, because none of the
|
||||
// work is reachable until that one block is written.
|
||||
// Every entry point below that changes anything joins the open transaction
|
||||
// group, and the group commits later: when it is SCHFS_COMMIT_TICKS old, on
|
||||
// sync(2) or fsync(2), on unmount and shutdown, or early when the blocks it
|
||||
// freed start to crowd out the free ones. That is how ext4 (5 s by default),
|
||||
// btrfs (30 s) and ZFS (5 s) all do it, and it is the same contract: a write
|
||||
// that returned is durable once it has been synced, and a crash loses at most
|
||||
// the last few seconds and never leaves a half-done operation, because a
|
||||
// commit is a single uberblock write that either lands or does not. There is
|
||||
// still no journal to replay and nothing to undo.
|
||||
//
|
||||
// The cost is two cache flushes and an uberblock write per operation, and it is
|
||||
// steep enough that callers doing many related changes should bracket them —
|
||||
// see schfs_ktx_open below. Measured over 1 MB of file data: 1410 block writes
|
||||
// It used to commit before every operation returned. Durable at once, and two
|
||||
// cache flushes plus an uberblock per write(2): 4 KB writes ran at 2.8 MB/s on
|
||||
// a disk that takes 5.6 in 64 KB ones, and every block of data cost about five
|
||||
// block writes, the same tree path rewritten once per call. Inside one group a
|
||||
// block the group already wrote is changed in place, so a path rewritten a
|
||||
// hundred times between commits goes to the disk once.
|
||||
//
|
||||
// Callers doing many related changes can still bracket them -- see
|
||||
// schfs_ktx_open below -- which keeps them in one group whatever the clock
|
||||
// does. Measured over 1 MB of file data: 1410 block writes
|
||||
// committing every 4 KB against 267 for the same megabyte as one group. Not a
|
||||
// tuning difference. A commit contains a flush, and a flush is a barrier, so
|
||||
// committing often is exactly the same thing as forbidding the buffer cache to
|
||||
|
|
@ -119,8 +129,22 @@ struct schfs_mount {
|
|||
int tx_owner_pid;
|
||||
int tx_depth;
|
||||
int tx_dirty;
|
||||
|
||||
// Changes not yet committed, and since when. Written under `lock`; the
|
||||
// flusher peeks at them without it, and a stale look only delays a commit
|
||||
// by one of its rounds.
|
||||
int dirty;
|
||||
uint dirty_since;
|
||||
};
|
||||
|
||||
// How long a change may wait for its commit, in 10 ms ticks: 5 s, ext4's and
|
||||
// ZFS's default. The flusher looks every second.
|
||||
// How much of a file one step of deleting it frees: 4 MB, 1024 blocks.
|
||||
#define SCHFS_FREE_STEP (1024ull * SCHFS_BLKSIZE)
|
||||
|
||||
#define SCHFS_COMMIT_TICKS 500
|
||||
#define SCHFS_FLUSH_TICKS 100
|
||||
|
||||
static struct schfs_mount kmnt[SCHFS_VOL_SLOTS];
|
||||
|
||||
// A mounted snapshot: the same block device with an older tree root, read-only.
|
||||
|
|
@ -289,6 +313,8 @@ schfs_lockinfo(int slot, int *locked, int *owner_pid, int *depth)
|
|||
*depth = m->tx_depth;
|
||||
}
|
||||
|
||||
static int kdirty_locked(struct schfs_mount *m);
|
||||
|
||||
void
|
||||
schfs_ktx_open(uint dev)
|
||||
{
|
||||
|
|
@ -317,7 +343,7 @@ schfs_ktx_close(uint dev)
|
|||
return 0;
|
||||
|
||||
if (m->tx_dirty)
|
||||
r = schfs_vol_commit(&m->vol);
|
||||
r = kdirty_locked(m);
|
||||
m->tx_dirty = 0;
|
||||
m->tx_owner = 0;
|
||||
m->tx_owner_pid = 0;
|
||||
|
|
@ -325,8 +351,117 @@ schfs_ktx_close(uint dev)
|
|||
return r;
|
||||
}
|
||||
|
||||
// Commit the open group, if it has anything in it. Caller holds m->lock and
|
||||
// is not inside someone's bracket. A failed commit leaves the group dirty, so
|
||||
// the next one tries again.
|
||||
static int
|
||||
kcommit_locked(struct schfs_mount *m)
|
||||
{
|
||||
if (!m->dirty || m->vol.readonly)
|
||||
return 0;
|
||||
int r = schfs_vol_commit(&m->vol);
|
||||
if (r == 0)
|
||||
m->dirty = 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
static void
|
||||
ksleep_ticks(uint n)
|
||||
{
|
||||
acquire(&tickslock);
|
||||
uint start = ticks;
|
||||
while (ticks - start < n)
|
||||
sleep(&ticks, &tickslock);
|
||||
release(&tickslock);
|
||||
}
|
||||
|
||||
// The thread that commits what nobody synced. Started by the first change.
|
||||
static void
|
||||
schfs_flusher(void)
|
||||
{
|
||||
for (;;) {
|
||||
ksleep_ticks(SCHFS_FLUSH_TICKS);
|
||||
for (int i = 0; i < SCHFS_VOL_SLOTS; i++) {
|
||||
struct schfs_mount *m = &kmnt[i];
|
||||
if (!m->mounted || !m->dirty ||
|
||||
ticks - m->dirty_since < SCHFS_COMMIT_TICKS)
|
||||
continue;
|
||||
acquiresleep(&m->lock);
|
||||
if (m->mounted) {
|
||||
int r = kcommit_locked(m);
|
||||
if (r != 0)
|
||||
cprintf("schfs: commit of volume %d failed (%d); retrying\n", i, r);
|
||||
}
|
||||
releasesleep(&m->lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note that the open group has changes, and commit it now if waiting would
|
||||
// hurt. Caller holds m->lock.
|
||||
//
|
||||
// Blocks a group frees stay unusable until it commits -- the committed tree
|
||||
// still points at them -- so a group rewriting a large file can tie up free
|
||||
// space that is really free. Once that is a quarter of what is left, the
|
||||
// group commits early rather than report ENOSPC on a disk with room on it.
|
||||
static int
|
||||
kdirty_locked(struct schfs_mount *m)
|
||||
{
|
||||
static int flusher;
|
||||
|
||||
if (!m->dirty) {
|
||||
m->dirty = 1;
|
||||
m->dirty_since = ticks;
|
||||
}
|
||||
if (!flusher && kthread_create(schfs_flusher, "schfsd") >= 0)
|
||||
flusher = 1;
|
||||
|
||||
struct schfs_space *sp = &m->vol.space;
|
||||
if (sp->nblocks && sp->pending > sp->free_blocks / 4)
|
||||
return kcommit_locked(m);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Is the volume down to the part of its reserve that new names may not use?
|
||||
// See schfs_space_low(). Caller holds the lock.
|
||||
static int
|
||||
klow(uint dev)
|
||||
{
|
||||
return schfs_space_low(&mnt_for(dev)->vol.space, 2, 0);
|
||||
}
|
||||
|
||||
// Shorten a file towards `size` in steps, from the end, each one a whole
|
||||
// operation the group may commit after -- as ext4 restarts its transaction
|
||||
// through a long truncate. The caller does the last step. Lock held.
|
||||
//
|
||||
// Freeing 80 MB at once deleted 20000 checksum records in one operation, and
|
||||
// every block that freed stays unusable until a commit, so `rm` on a full disk
|
||||
// ran out of room halfway through deleting and left the file half there.
|
||||
// Returns 0, or -1 if there is no such object.
|
||||
static int
|
||||
kshrink_locked(uint dev, uint64_t ino, uint64_t size)
|
||||
{
|
||||
struct schfs_vol *v = vol_for(dev);
|
||||
struct schfs_mount *m = mnt_for(dev);
|
||||
struct schfs_cursor c;
|
||||
struct schfs_inode in;
|
||||
|
||||
schfs_cursor_init(&c, &v->tree, curblk_for(dev));
|
||||
int r = schfs_inode_get(v, &c, ino, &in);
|
||||
if (r != 0 || in.type != SCHFS_T_FILE)
|
||||
return r;
|
||||
uint64_t at = in.size;
|
||||
while (r == 0 && at > size + SCHFS_FREE_STEP) {
|
||||
at -= SCHFS_FREE_STEP;
|
||||
r = schfs_file_truncate(v, ino, at, datablk_for(dev));
|
||||
if (r == 0 && !tx_mine(m))
|
||||
r = kdirty_locked(m);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Start and finish one transaction. `changed` says whether anything was
|
||||
// written, so a read does not pay for an uberblock write.
|
||||
// written, so a read does not dirty the group.
|
||||
static void
|
||||
ktx_begin(uint dev)
|
||||
{
|
||||
|
|
@ -345,7 +480,7 @@ ktx_end(uint dev, int changed, int r)
|
|||
return r;
|
||||
}
|
||||
if (changed && r >= 0) {
|
||||
int c = schfs_vol_commit(&m->vol);
|
||||
int c = kdirty_locked(m);
|
||||
if (c != 0)
|
||||
r = c;
|
||||
}
|
||||
|
|
@ -371,7 +506,7 @@ kd_read(void *ctx, uint64_t blk, void *out)
|
|||
return -1;
|
||||
|
||||
for (int i = 0; i < SCHFS_BPB; i++) {
|
||||
struct buf *b = bread(d->dev, (uint)(blk * SCHFS_BPB + i));
|
||||
struct buf *b = bread(d->dev, blk * SCHFS_BPB + i);
|
||||
memmove((char *)out + i * BSIZE, b->data, BSIZE);
|
||||
brelse(b);
|
||||
}
|
||||
|
|
@ -404,7 +539,7 @@ kd_write(void *ctx, uint64_t blk, const void *in)
|
|||
// into the last one, and costs nothing in safety because kd_flush below
|
||||
// gives the format the one ordering guarantee it actually asks for.
|
||||
for (int i = 0; i < SCHFS_BPB; i++) {
|
||||
struct buf *b = bgetblk(d->dev, (uint)(blk * SCHFS_BPB + i));
|
||||
struct buf *b = bgetblk(d->dev, blk * SCHFS_BPB + i);
|
||||
memmove(b->data, (const char *)in + i * BSIZE, BSIZE);
|
||||
bdirty(b);
|
||||
brelse(b);
|
||||
|
|
@ -435,9 +570,9 @@ kd_alloc_run(void *ctx, uint64_t n)
|
|||
}
|
||||
|
||||
static void
|
||||
kd_free(void *ctx, uint64_t b)
|
||||
kd_free(void *ctx, uint64_t b, uint64_t gen)
|
||||
{
|
||||
schfs_vol_free(&((struct schfs_mount *)ctx)->vol, b);
|
||||
schfs_vol_free(&((struct schfs_mount *)ctx)->vol, b, gen);
|
||||
}
|
||||
|
||||
// Pages for the allocation bitmap: one per 32768 blocks of volume, taken once
|
||||
|
|
@ -472,6 +607,7 @@ kmount_slot(int slot, uint bdev)
|
|||
return -EBUSY;
|
||||
|
||||
initsleeplock(&m->lock, "schfs");
|
||||
m->dirty = 0;
|
||||
|
||||
if (m->curblk == 0) m->curblk = kalloc();
|
||||
if (m->datablk == 0) m->datablk = kalloc();
|
||||
|
|
@ -534,6 +670,20 @@ kmount_slot(int slot, uint bdev)
|
|||
}
|
||||
|
||||
m->mounted = 1;
|
||||
|
||||
// Free what was unlinked while open and never closed: the space scan above
|
||||
// found them with no names. Nothing can reach them, so this is the last
|
||||
// chance to give their blocks back.
|
||||
if (m->vol.norphans && !m->vol.readonly) {
|
||||
int n = m->vol.norphans;
|
||||
for (int i = 0; i < n; i++)
|
||||
schfs_kfree(SCHFS_DEV + slot, m->vol.orphans[i]);
|
||||
schfs_ksync(SCHFS_DEV + slot);
|
||||
cprintf("schfs: freed %d object%s unlinked while open%s\n", n,
|
||||
n == 1 ? "" : "s", m->vol.orphans_more ? "; more next mount" : "");
|
||||
m->vol.norphans = 0;
|
||||
}
|
||||
|
||||
kinfo("schfs: mounted '%s' on device %d, %d MB, generation %d\n",
|
||||
m->vol.label, bdev,
|
||||
(int)(m->vol.total_blocks * SCHFS_BLKSIZE / (1024 * 1024)),
|
||||
|
|
@ -603,6 +753,9 @@ schfs_kumount(uint dev)
|
|||
return -EBUSY;
|
||||
|
||||
acquiresleep(&m->lock);
|
||||
if (kcommit_locked(m) != 0)
|
||||
cprintf("schfs: unmount: the last commit failed; changes since the "
|
||||
"previous one are lost\n");
|
||||
bflush(m->kdev.dev);
|
||||
// Give the allocation map back. It is rebuilt from the tree on every mount,
|
||||
// so there is nothing in it worth keeping -- and at 8 KiB per GiB of volume,
|
||||
|
|
@ -853,6 +1006,8 @@ schfs_kcreate(uint dev, uint64_t dir, const char *name, unsigned namelen,
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_create(vol_for(dev), dir, name, namelen, type, mode, major,
|
||||
minor, know(), out);
|
||||
return ktx_end(dev, 1, r);
|
||||
|
|
@ -878,7 +1033,9 @@ schfs_ktruncate(uint dev, uint64_t ino, uint64_t size)
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
int r = schfs_file_truncate(vol_for(dev), ino, size, datablk_for(dev));
|
||||
int r = kshrink_locked(dev, ino, size);
|
||||
if (r == 0)
|
||||
r = schfs_file_truncate(vol_for(dev), ino, size, datablk_for(dev));
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
||||
|
|
@ -899,6 +1056,8 @@ schfs_klink(uint dev, uint64_t dir, const char *name, unsigned namelen,
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_link(vol_for(dev), dir, name, namelen, ino);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
|
@ -928,17 +1087,26 @@ schfs_ksetattr(uint dev, uint64_t ino, const struct schfs_inode *in)
|
|||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
||||
// Flush without changing anything. Every operation above commits already, but
|
||||
// sync(2) has to mean something -- and with writes deferred into the buffer
|
||||
// cache it now means rather more than it did: an empty commit drives the same
|
||||
// barrier, so anything a bracket left dirty reaches the medium.
|
||||
// Commit the open group now. What sync(2) and fsync(2) come down to: the
|
||||
// group is the unit that commits, so making one file durable makes everything
|
||||
// written before it durable too, as on ext4's data=ordered.
|
||||
//
|
||||
// Inside a bracket the commit belongs to whoever opened it, so this only
|
||||
// marks the group, and the bracket's close commits it.
|
||||
int
|
||||
schfs_ksync(uint dev)
|
||||
{
|
||||
if (!writable_for(dev))
|
||||
return 0;
|
||||
ktx_begin(dev);
|
||||
return ktx_end(dev, 1, 0);
|
||||
struct schfs_mount *m = mnt_for(dev);
|
||||
if (tx_mine(m)) {
|
||||
m->tx_dirty = 1;
|
||||
return 0;
|
||||
}
|
||||
acquiresleep(&m->lock);
|
||||
int r = kcommit_locked(m);
|
||||
releasesleep(&m->lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Sync every mounted volume. What sys_sync wants: it names no filesystem.
|
||||
|
|
@ -986,6 +1154,8 @@ schfs_kalloc_object(uint dev, uint16_t type, uint32_t mode, uint64_t *out)
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
|
||||
struct schfs_vol *v = vol_for(dev);
|
||||
uint64_t ino = schfs_alloc_objectid(v);
|
||||
|
|
@ -1010,6 +1180,8 @@ schfs_kdiradd(uint dev, uint64_t dir, const char *name, unsigned namelen,
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_dir_add(vol_for(dev), dir, name, namelen, ino, type);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
|
@ -1034,7 +1206,9 @@ schfs_kfree(uint dev, uint64_t ino)
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
int r = schfs_object_free(vol_for(dev), ino);
|
||||
int r = kshrink_locked(dev, ino, 0);
|
||||
if (r == 0 || r == -1)
|
||||
r = schfs_object_free(vol_for(dev), ino);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
||||
|
|
@ -1057,6 +1231,8 @@ schfs_kreflink(uint dev, uint64_t src, uint64_t dst)
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_file_reflink(vol_for(dev), src, dst);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
|
@ -1072,6 +1248,8 @@ schfs_kattr_set(uint dev, uint64_t ino, const char *name, unsigned namelen,
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_attr_set(vol_for(dev), ino, name, namelen, val, vallen, type);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
|
@ -1106,6 +1284,8 @@ schfs_kindex(uint dev, const char *name, unsigned namelen)
|
|||
if (!writable_for(dev))
|
||||
return -1;
|
||||
ktx_begin(dev);
|
||||
if (klow(dev))
|
||||
return ktx_end(dev, 0, -3);
|
||||
int r = schfs_index_create(vol_for(dev), name, namelen);
|
||||
return ktx_end(dev, 1, r);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue