Full-filesystem writes: reservation, reclaim, and publication
Goblin Store treats filesystem capacity as a concurrent external resource. Several stores can be
in flight, other processes can consume the same filesystem, and unlinking a file does not release
its blocks until every open reader closes it. A cached free-byte count or statvfs sample cannot
make admission correct. The real openat, fallocate, or pwrite result is therefore
authoritative.
Known-size admission
The memcache protocol supplies the value length before its body. Goblin first computes the RAM,
SSD, and optional HDD layout, opens a new private generation on every used drive as
<64-hex-digest>.g<generation> with O_EXCL, and reserves the RAM head and write-staging block.
On Linux, each pool computes the exact final extent of each stripe shard. This includes the final
4 KiB alignment padding needed for O_DIRECT, but does not reserve empty files. Goblin then calls:
fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, shard_extent)
The call reserves physical blocks on the filesystem that will actually hold that shard without changing its apparent length. In three-tier mode, admission must succeed for both the SSD prefix and HDD tail. If a later shard fails, aborting the unpublished generation unlinks all shards already created or reserved for it.
ENOSPC and EDQUOT mean exhausted capacity. EOPNOTSUPP, ENOSYS, and an unsupported
FALLOC_FL_KEEP_SIZE reported as EINVAL mean that the filesystem cannot provide this guarantee;
Goblin continues with checked streaming writes instead. This fallback matters for some network and
older filesystems.
Other failures are not treated as invitations to evict. The failing device records the errno and
hardware-class write failures move it to a read-only or failed state. New disk-backed admissions
whose stripe layout touches that device then fail with read_only, rather than deleting healthy
cache entries in a futile retry loop. This keeps disk-full, unsupported reservation, and actual I/O
failure as three separate operational cases.
EVICT and BLOCK under concurrency
The storage caller selects EVICT or BLOCK for each admission, and the resulting StoreHandle
retains that choice through every block flush. EVICT and BLOCK writers may therefore run
concurrently through the same TierManager. Memcache passes its configured mode (default EVICT);
startup --source preloads explicitly pass the configured HTTP write mode (default BLOCK). HTTP
serving itself is read-only. The choice controls only the response to filesystem exhaustion; it is
not stored in ObjectMeta.
The normal file-creation and allocation attempts do not take the capacity coordinator, so writers remain concurrent while space is available. Only an out-of-space result enters the slow path.
A background maintenance thread uses the same coordinator and filesystem-local policy. It samples
live fstatvfs byte and inode availability and, once either use ratio reaches the configurable high
watermark, reclaims toward the low watermark. Work is bounded per pass and repeated at the
configured interval. This is proactive pressure control only: the real allocation/write result
remains authoritative because other processes and open-but-unlinked generations can change space
between samples.
Each pool directory is identified at startup by its st_dev. Directories backed by the same mount
share one capacity domain and one victim policy, even if they appear in different tiers; distinct
filesystems have distinct policies. A committed object is inserted into every capacity policy for
which it has a physical shard. RAM-only objects appear in none of them. The separate global
whole-object policy enforces the configured disk-backed-object count bound and is not used to
answer a filesystem-full event.
In EVICT mode, one TierManager-local coordinator serializes reclaiming writers. The failed
openat, fallocate, or pwrite identifies the st_dev of its drive. A writer takes the
coordinator and retries the real operation before evicting: another Goblin writer or an unrelated
filesystem user may have changed available space while it waited. If the retry still fails, Goblin
first prefers an expired disk-backed object that occupies that filesystem, otherwise asks that
filesystem’s capacity policy for a victim. It removes one complete object and retries. Success ends
the loop; exhaustion of eligible victims on the failed filesystem returns the capacity error. A
full HDD mount therefore cannot evict an SSD-only object on another st_dev.
For a BLOCK admission, Goblin does not enter that eviction loop. File creation, reservation, or a
streaming write that reports out of space fails the new store. The current indexed object, if any,
remains unchanged; the unpublished generation and its private RAM head are discarded. The caller
receives out_of_space. A future writable HTTP surface should map that error to
507 Insufficient Storage.
BLOCK does not make an object durable or permanently non-evictable. RAM-head allocation and
compaction use the same policy for both admission modes, and the global --max-objects bound is
enforced independently. After commit there is no BLOCK marker on the object: a later EVICT
admission can choose a disk-backed object from the failed filesystem’s capacity policy. The mode
means only “do not reclaim disk objects for this admission’s ENOSPC.”
On a filesystem without usable fallocate, capacity can run out after part of a body has arrived.
Goblin keeps the complete aligned staging block until it is written successfully. An EVICT
admission pauses that writer at the failed block, enters the same serialized reclaim/retry loop, and
replays the whole block. Replaying cannot damage a visible value because the destination generation
is still private. A BLOCK admission fails and aborts it instead.
Atomic visibility and delayed reclamation
The generation files are immutable after completion and are never renamed over a live object. When
all bytes are present, one in-memory Index swap publishes the new file_generation, metadata, and
RAM-head locator together. A reader captures that generation and opens its descriptors under the
same storage snapshot. Replacement then unlinks the old generation names, but readers that already
opened them keep the old inodes—and their blocks—until those descriptors close. Readers therefore
see one complete incarnation, while the allocation retry correctly observes whether eviction has
actually made enough space.
Capacity selection is local to the failed st_dev, but deletion remains whole-object: reclaiming an
object to free one full mount also removes its shards from any other mount it spans. This preserves
Goblin’s one-key/one-object semantics and updates every capacity policy together. Open readers can
still delay the physical block release, which is why each retry remains the final authority.
The same generation identity makes tail-read quarantine safe. A failed or short asynchronous read records the exact device error and removes only the generation captured by that reader. If a concurrent store has already published a newer generation, quarantine observes the mismatch and leaves it untouched. Existing readers retain their open descriptor while the poisoned pathname is unlinked; new lookups miss instead of repeatedly serving a known-bad object.
See ADR-0010 for the admission decision and ADR-0018 for the read/write publication model.
