Skip to content

feat(sqlite): fold a replica's own objects into one so a shared bucket stops growing - #668

Merged
pal-tamas merged 1 commit into
mainfrom
feat/crdt-compaction
Aug 10, 2026
Merged

feat(sqlite): fold a replica's own objects into one so a shared bucket stops growing#668
pal-tamas merged 1 commit into
mainfrom
feat/crdt-compaction

Conversation

@pal-tamas

Copy link
Copy Markdown
Owner

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.

Stacked on #667 (→ #666#665). Retarget as those merge.

var removed = await engine.CompactAsync();   // or automatically, past CompactAfterObjects (default 50)

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:

  • The replacement 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 as it is removed skips it and finds the replacement; the engine already
    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:

  • forty edits to one field leave the feed exactly the size it was after the insert;
  • 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 deleted rows on every new device,
    which is the failure mode worth guarding.
dotnet test tests/Rask.SQLite.Crdt.Sync.Tests                          # 43 passed, 4 skipped
RASK_CRSQLITE_PATH=… dotnet test tests/Rask.SQLite.Crdt.Sync.Tests     # 49 passed

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 format clean, dotnet build -warnaserror clean, 336 + 320 unit, 64 browser
journeys, 26 CLI build-gate.

…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
pal-tamas force-pushed the feat/crdt-compaction branch from b514d33 to ccec3ee Compare August 10, 2026 09:16
@pal-tamas
pal-tamas merged commit 25dae46 into main Aug 10, 2026
9 checks passed
@pal-tamas
pal-tamas deleted the feat/crdt-compaction branch August 10, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant