feat(sqlite): fold a replica's own objects into one so a shared bucket stops growing - #668
Merged
Conversation
…t stops growing Without this a device's prefix accumulates an object per sync forever, and a device joining a year-old family replays a year of them. CompactAsync() rewrites a replica's whole contribution as a single object and removes the rest; SyncAsync does it automatically once the prefix passes CompactAfterObjects (default 50, zero to disable). Stacked on feat/crdt-sample. What makes it cheap is a property of cr-sqlite's feed worth knowing on its own, and one I checked against the extension rather than assumed: THE FEED IS CURRENT STATE, NOT HISTORY. It holds one entry per (row, column) carrying the value that won, so editing the same field forty times leaves ONE entry, and a deleted row collapses to a single tombstone. Republishing everything therefore costs the size of the database rather than the number of edits ever made -- which is the only reason folding a whole prefix into one object is a sane thing to do. Had it been an append-only log, this design would have been pointless. No coordination is needed, because a replica only ever rewrites its OWN prefix. The rule that removes write conflicts is the same rule that makes compaction a purely local decision -- no lock, no lease, no agreement about when it is safe. Three things keep it correct, each pinned by a test: - The replacement is keyed so it sorts AT OR AFTER everything it replaces. A peer resumes from the last key it read, so a replacement sorting earlier would never be fetched -- and since it is the only object left, that peer would silently stop receiving anything from this replica. This is the property the whole design rests on, so it is asserted directly rather than implied by a round trip. - Re-reading state a peer already holds is harmless, because applying a change twice does nothing. That is what lets the replacement cover everything rather than only what is new. - A peer reading an object at the moment it is removed skips it and finds the replacement; the engine already treats a vanished object as ordinary rather than as an error. The replacement is written BEFORE anything is deleted: a crash between the two leaves duplicate state in the bucket, which is harmless, where the other order could leave a peer with neither. The empty case is deliberate rather than an oversight. A replica whose local changes have all since been overwritten by peers, or whose rows were deleted (the tombstone carries the DELETER's identity), contributes nothing -- so its stale objects are removed and nothing is written. The peers that now own those changes publish them, so nothing is lost. Verified against two real replicas, not only the fake feed: forty edits to one field leave the feed the size it was after the insert, and a brand-new device reaching a compacted prefix gets the surviving row AND stays free of the deleted one -- a tombstone that did not survive compaction would resurrect a deleted row on every new device, which is the failure mode worth guarding.
pal-tamas
force-pushed
the
feat/crdt-compaction
branch
from
August 10, 2026 09:16
b514d33 to
ccec3ee
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Without this a device's prefix accumulates an object per sync forever, and a device joining a year-old
family replays a year of them.
Why this is cheap — a property I checked rather than assumed
cr-sqlite's change feed is current state, not history. It holds one entry per (row, column) carrying
the value that won, so editing the same field forty times leaves one entry, and a deleted row collapses
to a single tombstone. Republishing everything therefore costs the size of the database rather than the
number of edits ever made.
Probed against the real extension before designing around it: 13 entries after an insert, still 13 after
five more edits, only the latest value present. Had it been an append-only log, folding a prefix into one
object would have been pointless and this would need a different design entirely.
Why it needs no coordination
A replica only ever rewrites its own prefix. The rule that removes write conflicts is the same rule
that makes compaction a purely local decision — no lock, no lease, no agreement about when it is safe.
Three things keep it correct, each pinned by a test:
so a replacement sorting earlier would never be fetched — and since it is the only object left, that
peer would silently stop receiving anything from this replica. This is the property the whole design
rests on, so it is asserted directly rather than implied by a round trip.
That is what lets the replacement cover everything rather than only what is new.
treats a vanished object as ordinary rather than an error.
The replacement is written before anything is deleted: a crash between the two leaves duplicate state
in the bucket, which is harmless, where the other order could leave a peer with neither.
The empty case is deliberate
A replica whose local changes have all since been overwritten by peers — or whose rows were deleted, since
the tombstone carries the deleter's identity — contributes nothing. Its stale objects are removed and
nothing is written. The peers that now own those changes publish them, so nothing is lost.
Testing
Verified against two real replicas, not only the fake feed:
one — a tombstone that did not survive compaction would resurrect deleted rows on every new device,
which is the failure mode worth guarding.
One expectation of mine was wrong and the code was right: compacting 8 objects removes 7, because the
replacement's key is the newest object's key and replaces it in place. Fixed the test, kept the
behaviour.
Local gates:
dotnet formatclean,dotnet build -warnaserrorclean, 336 + 320 unit, 64 browserjourneys, 26 CLI build-gate.