Expand description
Space admission: the disk floor, counted against the bytes still arriving.
The disk-headroom gate (crate::headroom) answers ONE question before a
push’s first byte: is the data set under its floor right now. It cannot
answer the question that matters when eight pushes arrive at once, because a
pack header carries an object count and no byte count, and every one of the
eight is admitted against the same measurement.
§The defect this closes (cov-13, the UpCloud mockup, 2026-10-06)
Eight concurrent 512 MiB pushes into gunnar main’s 19.9 GiB set, 2 GiB floor.
Each was admitted at the door with room to spare; together they ran /data out
of space while still receiving, and the store failed them MID-RECEIVE as
uring: op … failed: Operation canceled — an ENOSPC that does not say so, a
client told “the repository is unavailable; try again”, and the floor the
gate exists to keep simply crossed.
§What one pushed byte costs on /data (MEASURED, tests/push_space_admission.rs)
A push’s bytes land three times before one of the copies is let go:
- the spool — a pack of 64 MiB or more is spooled to
<repo>/gunnar-git.znippy/incoming-*.packwhile it arrives (gunnar-server::storage::SPOOL_ABOVE), so RAM never holds it; - the transaction log — the pack’s exact bytes are a
Packframe before the store sees one of them (the twin’s log; raw, not compressed); - the store — the archive appends the pack verbatim.
Then the spool is deleted. So the PEAK is PUSH_COPIES = 3 × the pushed
bytes and the steady state 2 × until the log is trimmed (the log’s byte
bound, gunnar-server::retention). The ledger reserves the peak and gives
the copies back as they actually land on the disk, so a byte is counted
once: as a promise until it is written, as statvfs after.
§The rule
One server-wide SpaceLedger: the bytes promised to transfers still
landing. A transfer takes a SpaceHold and asks it, chunk by chunk,
BEFORE the chunk is read off the wire:
admit c ⇔ free − floor − promised − (lane, for a big push) ≥ 3.5 × c
(3 copies, each with the filesystem's slack: XFS counts what it preallocates past a growing
file's end — MEASURED at start, [`FsSlack`]; never under [`PUSH_FS_SLACK_PERMILLE`].)measured with one statvfs per ask under the ledger’s lock, so two pushes
can never both be granted the same byte. A chunk that does not fit is
granted in part; a push that is granted nothing is REFUSED — that push, not
the others — with FULL_NOW’s line: its reservation is given back at
once, its spool is deleted by its own failing ingest, and nothing of it was
framed or stored. The refusal is final for that push and transient for the
data set: try again later.
§Fair: one huge push must not starve everything
- The small-push lane. A push that has sent more than
SMALL_PUSH_BYTES(16 MiB) may not take the lastSMALL_PUSH_LANE(256 MiB) above the floor. A big push is therefore refused while there is still room for ordinary pushes, and a 40 KiB commit keeps landing beside a 512 MiB import that does not fit — the import is the one told “full”. - Whoever cannot be granted is refused, and gives its room back. N equal
pushes into room for M < N of them do not all stall at a shared line: the
first one short is refused and its promise (and spool) returns to the
others, so the remaining ones finish. Bytes are granted in
LEASE_BYTESsteps, so a big push is never granted the room ahead of the push beside it.
§What stays OUT of the ledger, on purpose
Admin.Capacity (and so monetize’s emergency-growth trigger, which reads
used = total − servable) is the RAW statvfs — what is on the disk. A
promise is not a byte used: a burst that is refused would otherwise read as
a full set for the seconds it lasted and buy a growth nobody needed. The
bytes that DO land move used as they land, which is when the trigger
should see them.
What a push writes AFTER its hold is given back — the indexer’s rows,
journal pages, a Refs frame — is not reserved: it is small beside the pack
and it is what the floor itself is kept for.
Structs§
- FsSlack
- The filesystem’s slack as this data set’s ledger reserves it.
- Space
Hold - One transfer’s promise. Asked before each chunk (
Self::admit), told as its copies land (Self::realize), given back when it drops. - Space
Ledger - The server-wide account of bytes promised to transfers still landing.
One per data root;
gunnar servebuilds it with the disk-headroom gate and hands the sameArcto every door that writes (push, mirror, LFS). - Standing
- One reading of the data set and of what is promised on it.
- Statvfs
- The probe that ships: one
statvfson the data root.
Constants§
- FULL_
NOW - The start of every refusal for room, so a client, a kit and a test can
recognise it. Deliberately NOT the floor gate’s phrase
(
crate::headroom::AT_FLOOR): that one says the set is AT its floor and stays so until space is reclaimed or grown — final — and tools stop on it. This one says this push does not fit beside what is already arriving — try again. - LEASE_
BYTES - The most wire bytes one ask grants: one
statvfsper 4 MiB a push sends. - MIN_
ROOM - Less room than this above the floor and the promises — one 64 KiB chunk at
PUSH_COPIES— and the door says full before the first byte. - PROBE_
BYTES - The probe’s spool-sized write (
measure_fs_slack): grown in 1 MiB steps past 32 MiB, so a doubling filesystem shows its worst (right after a power of two) inside it. - PROBE_
FILE_ PREFIX - The probe’s file names (
<root>/.gunnar-space-probe-<pid>-<n>): one a crash left behind is deleted by the next probe. - PROBE_
FROM - The probe measures from here on: a push this size or bigger is a big push
(
SMALL_PUSH_BYTES); what a smaller file holds over its bytes is small beside the floor. - PROBE_
NEEDS_ FREE - The probe needs this much free above nothing to run (its file and what the filesystem
preallocates for it, twice over); less, and the slack is
UNMEASURED_SLACK_PERMILLE. - PUSH_
COPIES - How many bytes of /data one pushed byte occupies at its peak: spool, transaction-log frame, store. See the module header for the measurement.
- PUSH_
FS_ SLACK_ PERMILLE - The filesystem’s slack on top of the copies, ‰ (r5.1, 2026-10-07): what the data set’s filesystem counts as used beyond the bytes written while a file grows. XFS — the appliance’s /data — preallocates past a growing file’s end and counts it used until the file is closed (or trimmed): MEASURED on XFS, one 96 MiB push peaked at 3.24× its bytes on the data set and held 2.16× after it landed, against the three copies (tmpfs: under 3.15×). 1/6 on top of every copy: a promise of 3.5× per wire byte, given back when the hold drops.
- PUSH_
RESERVE_ PERMILLE - What the ledger promises per wire byte of a framed push, ‰, at the least: the
PUSH_COPIESwith the floor of the filesystem’s slack (PUSH_FS_SLACK_PERMILLE). A ledger built with a measuredFsSlackpromises more where the filesystem preallocates more. - SLACK_
MARGIN_ PERMILLE - The margin on top of what was measured, ‰.
- SMALL_
PUSH_ BYTES - A push that has sent more than this is a BIG push, held out of the
SMALL_PUSH_LANE. 16 MiB is an ordinary commit’s pack many times over. - SMALL_
PUSH_ LANE - The room above the floor a big push may never take, kept for small pushes:
at
PUSH_COPIESthat is five pushes ofSMALL_PUSH_BYTESat once, or thousands of ordinary ones. - UNMEASURED_
SLACK_ PERMILLE - The slack when the filesystem could not be measured, ‰: XFS’s doubling (1000 ‰) and the margin — the most a growing file was seen to occupy. Conservative on purpose: it only refuses a big push sooner near the floor.
Traits§
- Space
Probe - What the ledger measures:
(free, total)in bytes,freeas an unprivileged writer sees it (f_bavail).
Functions§
- is_
out_ of_ space - Is this error the disk being full?
ENOSPC,EDQUOT, or theStorageFull/QuotaExceededkinds std maps them to — found anywhere in the chain ananyhoworio::Errorcarries.Operation canceled(125) is how io_uring reports the tail of a linked chain whose head hit ENOSPC, so it counts only beside a disk that measures at its floor (SpaceHold::at_the_floor). - measure_
fs_ slack - What
dir’s filesystem occupies beyond a growing file’s bytes, ‰ of them, at worst: aPROBE_BYTESfile is written in 1 MiB steps and itsst_blocksread after each step fromPROBE_FROMon and once more after anfdatasync, all while it is open — the way a pushed pack’s spool, frame and store grow. The file is deleted before this returns; one a crash left is deleted by the next. Refused when less thanPROBE_NEEDS_FREEis free. - statvfs_
bytes (f_bavail, f_blocks)in bytes for the filesystemrootlives on.