Crash-safe append logs with fsync budgets
Durability on a field gateway is bought with flash writes, and the exchange rate is worse than it looks: a 34-byte record synced individually can cost a 4 KB page program plus a journal update, an amplification above 200×. This guide sets the exchange rate deliberately — how much data a power cut may lose, and what that permission buys back in flash lifetime and write latency — for the append log built in store-and-forward buffering, inside the Bandwidth & Async Sync Optimization guide.
What fsync actually guarantees, and what it does not
A write() returns once the bytes are in the kernel’s page cache. They may reach the device seconds later, or never if power is lost. fsync() returns once the device reports the data durable — which on consumer flash means “in the device’s own cache and covered by its capacitors”, a claim not every part honours.
Three consequences follow for an append log. First, the ordering between a payload write and any metadata that describes it is not guaranteed without a barrier, which is why the record’s own header carries its length and checksum rather than a separate index carrying them. Second, fsync on the file does not make a rename durable — the containing directory needs its own sync, a step that is skipped in most code and only matters after a power cut, which is to say only in the field. Third, some cheap cards lie: they acknowledge a flush before the data is durable. The mitigation is not code, it is a purchasing decision and a power-cut test on the bench.
The budget, made explicit
# fsync_budget.py — append with an explicit durability window per stream.
# Each stream states how much loss a power cut may cause; the writer converts
# that into a sync cadence and enforces it. Nothing here is best-effort.
import os
import time
from dataclasses import dataclass
@dataclass(frozen=True)
class DurabilityPolicy:
"""max_loss_records: how many records a power cut may cost this stream.
max_loss_seconds: the same bound expressed in time; whichever binds first
triggers the sync."""
max_loss_records: int
max_loss_seconds: float
POLICIES = {
"events": DurabilityPolicy(max_loss_records=0, max_loss_seconds=0.0),
"positions": DurabilityPolicy(max_loss_records=200, max_loss_seconds=5.0),
"diagnostics": DurabilityPolicy(max_loss_records=2000, max_loss_seconds=60.0),
}
class BudgetedWriter:
__slots__ = ("fh", "policy", "since_sync", "last_sync", "syncs", "bytes_written")
def __init__(self, path: str, policy: DurabilityPolicy):
self.fh = open(path, "ab", buffering=0)
self.policy = policy
self.since_sync = 0
self.last_sync = time.monotonic()
self.syncs = 0
self.bytes_written = 0
def append(self, framed: bytes) -> None:
self.fh.write(framed)
self.bytes_written += len(framed)
self.since_sync += 1
if self._should_sync():
self._sync()
def _should_sync(self) -> bool:
p = self.policy
if p.max_loss_records == 0:
return True # sync every record
if self.since_sync >= p.max_loss_records:
return True
return (time.monotonic() - self.last_sync) >= p.max_loss_seconds
def _sync(self) -> None:
os.fsync(self.fh.fileno())
self.since_sync = 0
self.last_sync = time.monotonic()
self.syncs += 1
def close(self) -> None:
self._sync()
self.fh.close()
def durable_rename(src: str, dst: str) -> None:
"""Rename plus the directory sync that makes it survive a power cut.
Omitting the directory fsync is the most common durability bug in this
whole area, and it only manifests after an unclean shutdown."""
os.rename(src, dst)
dir_fd = os.open(os.path.dirname(dst) or ".", os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
Expressing the policy as “how many records may be lost” rather than as “sync every N” puts the decision where it belongs: with whoever owns the data, not with whoever wrote the writer. An event stream declaring zero acceptable loss gets a sync per record and pays for it; a diagnostics stream declaring two thousand gets one sync a minute and costs the flash almost nothing.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| Flash endurance | Per-record syncs amplify writes by two orders of magnitude | Sync cadence derived from a declared loss budget; only the event stream pays the full price |
| Latency | fsync blocks the calling thread for 2–40 ms on eMMC |
Batched for the high-rate streams; the event stream’s sync is on a low-rate path |
| Power loss | Unsynced records are lost | The window is explicit and bounded, and the torn tail is detectable on the next boot |
| Correctness | A rename can be lost even after the file is synced | durable_rename syncs the directory as well |
| CPU | Sync is I/O wait, not CPU, but it holds the thread | The writer runs on the spool task, never on the acquisition thread |
Gotchas and edge cases
fsyncon a file does not sync its directory entry. Creating a file, writing it, syncing it and renaming it still leaves the rename in the page cache. Every atomic-replace pattern in this section depends ondurable_rename, and every one that omits the directory sync is subtly broken.fdatasyncis not always cheaper. It skips metadata that has not changed, which for an append that extends the file is exactly the metadata that did change. On an append log the two usually cost the same; measure before assuming otherwise.- A sync that fails must not be ignored. On some kernels a failed
fsyncmarks the error consumed, so a retry returns success while the data is gone. Treat anOSErrorfromfsyncas fatal for the writer: close, reopen, and re-verify the tail rather than continuing. - Latency spikes are not the same as throughput loss. A 40 ms sync every five seconds is invisible in aggregate and very visible if it lands inside a 50 ms deadline. Keep the writer off any thread with a deadline, which is the same separation the async execution guide applies to compiled work.
- Test with real power cuts. Killing the process tests the first box in the diagram and nothing else. Cutting power at the barrel jack, repeatedly, under load, is the only test that covers the other three — and it is the test that finds a card whose flush acknowledgement is a lie.
Proving it on the bench
Build the power-cut rig once and it pays for itself across every project. A relay on the supply, a script that writes at the production rate with the production policy, and a loop that cuts power at randomised intervals, reboots, and validates. Each cycle checks that the log reopens, that the torn tail is trimmed at a record boundary, that every surviving record’s checksum passes, and that the number of records lost is inside the declared budget.
Fifty cycles is usually enough to find the interesting faults: the missing directory sync, the writer that buffered when nobody expected it, the card whose flush is advisory. None of those is visible in code review, all of them are visible within an hour of automated power cutting, and each of them produces field data loss that would otherwise be attributed to something else entirely.
Framing that makes recovery decidable
The recovery routine’s job is to answer one question about the tail of a file: is this a complete record or the debris of an interrupted write? Everything about the frame format either helps or hinders that.
Four fields, in this order, make it decidable with no ambiguity. A length first, so the reader knows how much to consume before consuming it. A checksum over the payload, so a fully written record with corrupted bytes is caught. A kind tag, so a reader can skip a record type it does not understand rather than aborting. And a monotonic timestamp, so ordering survives a clock step.
Two things are deliberately absent. There is no magic number at the record boundary, because a magic number tempts a reader into resynchronising after damage — scanning forward for the next one — and on an append-only file that is never the right behaviour: damage is at the tail, and anything after it is by definition suspect. And there is no back-pointer to the previous record, because maintaining one means writing into the previous record after the fact, which turns an append-only file into a random-write one and forfeits the entire crash-safety argument.
The recovery rule that falls out is a single sentence: read forward from the last known-good offset, stop at the first record that fails length or checksum validation, truncate there. It has no special cases, it terminates, and it produces the same result whether the interruption happened between two records, in the middle of a header, or in the middle of a payload.
A final note on where this discipline stops. Everything above concerns a single file on a single device, and it is sufficient for exactly that. The moment a design needs two files to be consistent with each other — a spool and a separate cursor, say — the guarantees have to be re-derived, because there is no ordering between two independent fsync calls. The way out is either to put both pieces of state in the same file, so one sync covers them, or to make one of them reconstructible from the other, so an inconsistency after a power cut has an unambiguous resolution. The cursor described in the parent guide takes the second route: if it is lost or stale, replaying from an earlier offset produces duplicates the upstream already knows how to absorb.
Related
- Store-and-Forward Buffering — the append log and its recovery path.
- Sizing a durable spool on NAND flash — the endurance budget these syncs consume.
- Brownout-safe writes for battery-powered gateways — the same problem seen from the power side.