Malachi. Storage. Preallocation
(malachi v0.22.7)
View Source
Extends a segment file to its full size before it is written, so that appends land inside an already-sized region instead of growing the file.
This exists because of what a group commit actually pays for. A 1-byte fsync measured 303us on
the CI runner and a 2.5KB one measured 341us, so the bill is almost entirely fixed cost: the
filesystem's journal commit, not the transfer. fdatasync(2) is supposed to skip that by flushing
only the metadata needed to read the data back, but while a segment GROWS there is always such
metadata, because every append changes the file's size. Preallocation is what removes the size
change, and only then does fdatasync have anything to skip (issue #83, blocking #82).
The three mechanisms are not equivalent
fdatasync must journal anything needed to retrieve the data, so what each mechanism leaves
behind for the first write to do is the whole point:
| mechanism | changes i_size per append | allocates a block on first touch | extent conversion |
|---|---|---|---|
| growing (no preallocation) | yes | yes | n/a |
:sparse | no | yes | n/a |
:allocate | no (Linux) | no | yes, unwritten to written |
:zeros | no | no | no |
Only :zeros leaves an append as a pure data write: posix_fallocate(3) marks the extents
UNWRITTEN, and converting one to written on first touch is itself a journaled metadata update.
It is why PostgreSQL pre-creates WAL segments by writing zeros rather than by calling fallocate.
That is a mechanism argument, not a measurement, which is why all three are kept here and
benchmark/storage_viability.exs measures them against both syncs before one is chosen.
It is a trade, and the flush size decides which way it goes
Preallocation takes metadata out of the commit, which is most of the bill when a flush is a few
KB. It also gives up the filesystem's delayed allocation, which is what the bill is made of when a
flush is large and the transfer dominates. Measured on an ubuntu-latest runner, one sync per
flush, 15 interleaved repetitions per arm with an A-A control (benchmark/storage_viability.exs
stage 3):
| bytes per flush | p50 | p99 |
|---|---|---|
| 2.5KB | -69.7% | -47.2% |
| 25KB | -68.2% | -33.7% |
| 64KB | -41.2% | -11.7% |
| 128KB | -2.7% | -6.7% |
| 256KB | +5.0% | +10.5% |
| 512KB | +13.4% | +81.7% |
| 1MB | +16.2% | +258.0% |
Both turn over around 170KB per flush, interpolating between the measured points: the p50 crosses zero near 173KB and the p99 near 178KB. What separates them is not where they cross but how steeply they fall afterward. Past the crossover the median drifts (+5.0%, +13.4%, +16.2%) while the tail runs away (+10.5%, +81.7%, +258.0%), so a deployment defending a p99 has far more to lose from being on the wrong side of it than one watching the median, even though both sit on the same side.
What sets the flush size is what a producer sends per produce, or what group commit coalesces,
NOT :flush_bytes, which is only a ceiling (10MB by default). The pinned ceiling harness runs
2.5KB per flush, fifty times below the crossover.
Why written zeros cost more to verify later
Worth knowing before anyone proposes the obvious optimization. A filesystem can answer "where does
the data start" in constant time for SPARSE regions, through SEEK_DATA, which would make checking
that a preallocated tail is really unwritten free instead of proportional to its size
(Malachi.Storage.ElixirStore, the blank-tail probe). It does not help here: written zeros are
allocated, written blocks, and SEEK_DATA cannot tell them from data. The mechanism that makes the
append cheap is the same one that makes the later verification expensive, and :sparse would trade
that back at the cost of the win this module exists for. Erlang does not expose SEEK_HOLE without
a NIF in any case.
What it assumes about the filesystem
Preallocation turns every append from an extend into an overwrite of already-allocated blocks.
On ext4 and xfs that is in-place and is exactly the saving. On copy-on-write filesystems (btrfs,
zfs) overwriting allocated blocks is more expensive than appending, so preallocation can make
latency worse there. That is why it is an explicit operator choice (:prealloc_bytes, off by
default) rather than a default.
Every function here is a plain function over an open file descriptor: no process, no state, and
no knowledge of segments, so Malachi.Storage.ElixirStore and the benchmark can measure and run
the same code.
Summary
Types
How to extend the file.
Functions
Extends the file behind file_descriptor from from_byte to to_byte, so that reading anywhere
in [from_byte, to_byte) yields zeros.
Extends like extend/4 and makes the extension durable with a sync, or, if either fails, puts the file
back to from_byte.
The size of the file behind file_descriptor, in bytes.
Types
@type strategy() :: :sparse | :allocate | :zeros
How to extend the file.
:sparse- move to the new end and truncate there, which sets the size without allocating any block. Free to do, and leaves every block allocation to the first write.:allocate-:file.allocate/3(posix_fallocate(3)on Linux,F_PREALLOCATEon macOS). Allocates without writing, and falls back to:zeroswhere the platform cannot do it.:zeros- write zeros over the whole region. Costs one full-size sequential write at creation, and is the only mechanism that leaves nothing for the first append to journal.
Functions
@spec extend(:file.fd(), non_neg_integer(), non_neg_integer(), strategy()) :: :ok | {:error, term()}
Extends the file behind file_descriptor from from_byte to to_byte, so that reading anywhere
in [from_byte, to_byte) yields zeros.
from_byte is the file's current size. Returns :ok, or {:error, reason} from the underlying
file operation. A to_byte at or below from_byte is a no-op rather than an error, so a caller
that recovers a segment already longer than its preallocation target never shrinks it: shrinking
is Malachi.Storage.ElixirStore's decision to make, on the seal and close paths, and never a
side effect of asking for room.
:allocate verifies that the file's size actually changed, because F_PREALLOCATE reserves
blocks without necessarily moving the size, which would leave every append still extending the
file and quietly measure nothing. Whatever gap is left is written as zeros.
@spec extend_or_restore(:file.fd(), non_neg_integer(), non_neg_integer(), strategy()) :: :extended | :restored | {:error, term()}
Extends like extend/4 and makes the extension durable with a sync, or, if either fails, puts the file
back to from_byte.
Answers :extended, or :restored when the extension or its sync failed and the file is from_byte
long again, or {:error, reason} when putting it back failed too. That last one is not a degraded
success: the file may keep part of an extension that nothing will trim, so a caller must not go on using
the descriptor as though it had never been extended.
@spec file_size(:file.fd()) :: {:ok, non_neg_integer()} | {:error, term()}
The size of the file behind file_descriptor, in bytes.
Reads it from the descriptor rather than from the path, so it describes the file this handle is writing to even if the name has since been replaced, and so no caller needs the path just to ask.