Skip to main content

Module space

Module space 

Source
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:

  1. the spool — a pack of 64 MiB or more is spooled to <repo>/gunnar-git.znippy/incoming-*.pack while it arrives (gunnar-server::storage::SPOOL_ABOVE), so RAM never holds it;
  2. the transaction log — the pack’s exact bytes are a Pack frame before the store sees one of them (the twin’s log; raw, not compressed);
  3. 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 last SMALL_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_BYTES steps, 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.
SpaceHold
One transfer’s promise. Asked before each chunk (Self::admit), told as its copies land (Self::realize), given back when it drops.
SpaceLedger
The server-wide account of bytes promised to transfers still landing. One per data root; gunnar serve builds it with the disk-headroom gate and hands the same Arc to 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 statvfs on 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 statvfs per 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_COPIES with the floor of the filesystem’s slack (PUSH_FS_SLACK_PERMILLE). A ledger built with a measured FsSlack promises 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_COPIES that is five pushes of SMALL_PUSH_BYTES at 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§

SpaceProbe
What the ledger measures: (free, total) in bytes, free as an unprivileged writer sees it (f_bavail).

Functions§

is_out_of_space
Is this error the disk being full? ENOSPC, EDQUOT, or the StorageFull/QuotaExceeded kinds std maps them to — found anywhere in the chain an anyhow or io::Error carries. 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: a PROBE_BYTES file is written in 1 MiB steps and its st_blocks read after each step from PROBE_FROM on and once more after an fdatasync, 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 than PROBE_NEEDS_FREE is free.
statvfs_bytes
(f_bavail, f_blocks) in bytes for the filesystem root lives on.