LFFS: Simplicity vs Efficiency
Brian Hicks, December 26, 2024
I knew it was gonna happen, just knew it: I finally hit the first part of writing the book where I have to make a choice between something being easy to describe and understand up front vs long-term efficiency or flexibility.
Let me explain.
When you have a state-based CRDT, you have a merge
operation. The details don't really matter for this explanation, but basically if merge
is commutative, idempotent, and associative, you can make use it for a CRDT and syncing state is as simple as:
- Exchanging states with a peer.
- Both sides call
merge
to get the final state.
With a few more supporting details, that's easy to understand and explain! (Even easier because in the book the only peer is a sync server.)
However this sync protocol gives up efficiency for that simplicity: you have to send all the data back and forth over the network. Most of the time, you're gonna be sending a bunch of duplicate data with a few changes, which means you're actually sending all the data twice.
You can improve efficiency by sending only the things that changed: if I've got a grow-only set, and I add 1
to it, I only send 1
to my peer. In fact, you can keep track of all the tiny pieces of data as separate instances of the CRDT, and only send the ones you care about. For example, the set {1, 2, 3}
and the sets {1}
, {2}
, and {3}
(when merged) represent the same data.
This implies that merge
can be inverted. We'll call that operation split
. Like merge
, it needs to have a couple of properties:
split
should produce irreducible CRDTs.merge
ing the results of split
should give you the original state.
The literature I've read claims that you can split
all CRDTs, and I haven't found any counterexamples yet!
That lets us be even more efficient. Our sync protocol can look like this:
- Keep track of the changes you make but haven't sent to each peer, either as a single instance of the CRDT or as a list of
split
results. - Send only those changes during sync. (But
merge
them before sending, because that usually reduces the total byte size.) - When you receive changes,
split
what you get and remove any parts that you already knew about. - Add any new parts to the list of changes you send on to other peers (but not the peer you got them from.)
Vitor Enes et al have a paper and blog post on this sync protocol if you want more details.
Anyway, it seems like these protocols would be pretty easy to build up to (since split
is just exposing a specific way of thinking about merge
.) I'm having a hard time, though, because I need a way to store the CRDT bits earlier than I want to put the syncing details. Although, I suppose I could:
- Store the whole replica state as a JSON blob locally.
- Introduce the sync server and introduce
split
to deal with storage in Postgres there. - Implement the first sync protocol as a full state sync.
- Improve to delta-state sync from there.
Might work, might not. I guess we'll see!