Skip to content

Why FileSink holds a lock

JSON Lines has exactly one rule: every line is a complete JSON object. Honour it and the file can be tailed, grepped and streamed. Break it once and every line in the file becomes suspect, because a reader can no longer assume a line is a record.

FileSink therefore has one job beyond writing the right bytes: never let two events share a line.

Append mode is a smaller guarantee than it sounds

The intuitive model is that opening with O_APPEND makes concurrent appends safe — each write goes to wherever the end currently is, so writers cannot tread on each other.

Half of that is true. O_APPEND does make the seek-to-end and the write a single unit, so you never get two writers computing the same offset and clobbering one another. What it does not promise, on POSIX, is that a single write() of arbitrary size is atomic with respect to other writers. That atomicity has a ceiling, and the ceiling is PIPE_BUF — 4096 bytes on Linux. Below it, a write lands all-or-nothing against other writes. Above it, the kernel may split the write, and another writer's bytes can land in the gap.

Which is fine until an event gets fat

A typical event — a name, a timestamp, an attribute or two — serialises to a few hundred bytes, comfortably inside the atomic window. The failure only appears once an event carries enough attributes to push its serialised form past 4 KiB, and only when two such events are emitted at the same moment. The result is one spliced line and a file no parser will accept.

That is a bug that hides for a long time and then arrives looking like data corruption, which is why the sink does not rely on the kernel for it.

The fix is a gate, not a bigger write

There is no buffer size that is reliably atomic above PIPE_BUF, so the sink stops relying on the kernel for mutual exclusion and does it itself. FileSink carries an Arc<tokio::sync::Mutex<()>> and takes it around the whole create-open-write-flush sequence, so only one write is ever in flight.

Two details are load-bearing. The event is serialised to a string before the lock is taken, because turning an event into JSON is the expensive part and there is no reason to hold the gate for it. And the mutex is behind an Arc, shared across Clones of the sink, so two handles to the same path share one gate rather than each getting a private lock that guards nothing.

The guarantee stops at the process boundary

A mutex cannot reach across processes. Two FileSinks in two different processes pointed at the same path are back to relying on O_APPEND alone, and back under the 4 KiB ceiling — which is to say, fine until an event gets fat, and then not.

The crate does not try to solve this with file locking. The older, duller answer is the right one: give each process its own file and aggregate them somewhere else. Two processes fighting over one log file while expecting the filesystem to referee is a design to back out of, not to shore up.