A Version Is Not Its Bytes

Coarse clocks, blind overwrites, and the two-peer editing branch landing on main

5 min read | KEIBIDROP Series

Where the last post left off

The previous post closed with two recorded gaps. If both peers announce before either accepts, the side that rejects cannot preserve bytes it never fetched. And two edits with identical nanosecond timestamps would each reject the other and never converge; we wrote that one down for the model checker and expected never to see it. The branch is merged now. Both gaps are closed, and the second one closed itself in the most direct way possible: we saw it, twice, on two operating systems.

One machine, two clocks

CI failed the two conflict tests while our development machines passed them in hundreds of loops. Pinning a Linux box to two cores reproduced the failure in a third of the runs, with full operation logs. The logs showed both peers rejecting each other's announcement, which the algebra says should be impossible: an announcement is supposed to carry at least its author's own version.

It carried less. When a file is saved, the write path stamps the in-memory version with Go's fine-grained clock, and that stamp is what acceptance defends. The announcement, though, was built from a fresh disk stat, and Linux stamps inodes with the coarse kernel tick, which advances every one to four milliseconds. Two writers landing inside one tick each announced the older coarse stamp. Each side's fine-grained identity beat both announcements, and both peers kept their own bytes forever. The fix is one comparison at the announce site: the announced time is the maximum of the disk stamp and the in-memory identity. The two-core reproduction went from failing one run in three to twenty-six clean runs in a row.

A machine has two clocks: the fine one your code reads and the coarse one the filesystem writes. Any protocol that reads a version from one and defends it with the other will disagree with itself under load. The rule that survives: an announcement may never understate its author.

The identical-timestamp case arrived through the same door. Our test pairs run both peers on one machine, so both write stamps come from one kernel's tick counter, and inside one tick they are equal to the nanosecond. On a Windows box that is a virtual machine, the fine clock itself is coarse, and roughly one run in eight produced an exact tie that the strictly-greater acceptance rejects from both sides. The deterministic tie-break, by peer fingerprint, is designed and modeled but not rushed into this branch; it is the one divergence case left, it needs a clock resolution measured in milliseconds plus a same-tick collision, and the conflict-copy machinery bounds the damage to both peers keeping their own version.

Windows, from underneath

This branch also carries the first fully green run of the whole editing suite on Windows. The port surfaced platform behavior worth writing down: the file handle a program opens does not reliably round-trip through the filesystem driver, which multiplexes user opens onto a cached context, so reads and writes resolve by path when the handle is stale. Renaming over an open file fails unless every handle was opened with delete sharing, which Go's standard library does not request. And the cache manager happily serves bytes from before an accepted update unless read-only opens bypass it. Two of these were confirmed from the outside when the filesystem driver's changelog later shipped "FUSE now respects the direct_io flag" and a fix for a notification deadlock with concurrent renames.

The internet is not loopback

With the suite green on three platforms we ran the same scenarios between a laptop and a server in another country, through the production relay. The first attempt died at room creation, before any file moved. The relay probes a registering peer's port to learn whether it is reachable: a connection that opens and immediately closes. Our room creation treated the first inbound connection as the peer, so the probe's EOF killed the room. On loopback this cannot happen; on the public internet it happens within a millisecond of registering, from our own infrastructure, and from every port scanner after that. The accept loop now drops connections whose handshake fails and keeps accepting until its deadline, with a per-connection handshake deadline so a connection that sends nothing cannot pin the slot. Junk gets zero bytes in response, so there is nothing to reflect at anyone.

After that, the numbers: edits converge in about 0.6 seconds in each direction, concurrent saves of the same file produce one conflict copy that appears on both machines, and a swap save based on a stale version preserves the newer version the same way it does on loopback.

Bytes you never fetched

The remaining gap from the last post fell to a simpler observation than the fetch-by-version mechanism we had sketched. KeibiDrop fetches file content on demand. Accepting an announcement moves metadata; the bytes move when somebody reads. So a peer can hold the announcement of a version and none of its content, overwrite the file wholesale, and announce a base equal to the version it displaced. To the receiver that base says turn-taking, no conflict, and the receiver was the only machine holding those bytes.

Accepting a version and holding its bytes are different facts, and the announced base was built from the wrong one. The base now declares the newest version whose bytes the writer actually held: raised by landed fetches and its own saves, never by a metadata accept. A session that held nothing declares exactly that. The receiver's existing conflict logic does the rest, and the only copy of a version survives as a conflict file instead of vanishing.

The model checker got a third model for this: it splits accepting from fetching and enumerates every interleaving of writes, deliveries, and fetches. With the old base rule it finds silent-loss histories at the smallest possible scope. With the held-bytes rule it finds none at any scope we can enumerate, and turn-taking through a real fetch stays copy-free. The first implementation attempt also taught us the domain rule the hard way: the held stamp is a logical value from the announcement clock, and the one place we stamped it from a local disk time broke the conflict tests immediately.

Where it stands

Merged to main. The suite is fourteen scenarios now, green on macOS, Linux under the race detector and at two cores, and Windows, plus the three model checkers and the cross-country run through the production relay. Open and known: the nanosecond tie-break by fingerprint, a teardown race where a stopped session's health monitor can linger and interfere with an immediate reconnect, and the unchanged stance that two writers inside one mmap'd database are unsupported over any network filesystem. Next is multi-user, which starts as research rather than code: with two peers, one comparison carries what a vector clock carries in general, and that convenience does not survive a third writer.