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:

mechanismchanges i_size per appendallocates a block on first touchextent conversion
growing (no preallocation)yesyesn/a
:sparsenoyesn/a
:allocateno (Linux)noyes, unwritten to written
:zerosnonono

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 flushp50p99
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

strategy()

@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_PREALLOCATE on macOS). Allocates without writing, and falls back to :zeros where 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

extend(file_descriptor, from_byte, to_byte, strategy)

@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.

extend_or_restore(file_descriptor, from_byte, to_byte, strategy)

@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.

file_size(file_descriptor)

@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.