Skip to content

fix(crdt): accept old offline edits, merge nothing on a hook rejection, stop pulls skipping rows - #35

Merged
juicycleff merged 3 commits into
mainfrom
fix/crdt-sync-defects
Oct 7, 2026
Merged

juicycleff merged 3 commits into
mainfrom
fix/crdt-sync-defects

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Three fixes to the CRDT sync server. Each one loses or refuses data a client sent in good faith, and each comes with a test.

Old offline edits are accepted again

Drift validation compared a change's HLC against server time in both directions, so a change stamped more than MaxHLCDrift (an hour by default) in the past was rejected. That's exactly what a device produces after a few days offline: you edit, the edits wait in the queue, and when you reconnect every one of them bounces. Now only changes stamped too far in the future are refused. Older changes are always accepted, since they're just late.

TestValidateChangeRecord_OldOfflineChangeAccepted covers it.

A hook rejection merges nothing

HandlePush ran BeforeInboundChange change by change, merging as it went. When a hook rejected change 5 of 10, changes 1 to 4 were already merged, but the client was told the whole push failed, so its idea of what the server holds was wrong. The hooks now run over the whole batch first, and a rejection fails the push with nothing applied. A hook that returns nil still skips just that change, as before.

TestSyncController_HandlePush_HookRejectionMergesNothing covers it.

A multi-table pull no longer skips rows

HandlePull and StreamChangesSince read each table with its own page limit, then moved the cursor to the newest HLC across all of them. If one table filled its page and another table had a newer row, the cursor jumped past the first table's unread rows, and the next pull never saw them. readChangesWindow now cuts the window at the earliest full-page end across the tables, so the next pull resumes there. The comparison uses (timestamp, counter) only, matching the shadow-table cursor.

TestSyncController_HandlePull_MultiTableBacklogNotSkipped covers it.

Running it

go test -race ./crdt/...

Wire shapes don't change, and neither do the merge or apply rules. Clients built against the current server keep working. The Dart client in #34 runs its convergence and conformance tests against this branch as well as main, and passes on both.

One server gap these fixes don't touch: a late-stamped merge of a counter or set still isn't redelivered to peers that already pulled past its HLC. #34 describes it, and has a skipped test for it.

@juicycleff
juicycleff merged commit 2219330 into main Oct 7, 2026
23 checks passed
juicycleff added a commit that referenced this pull request Oct 7, 2026
…ers per node (#36)

* fix(crdt): keep a shadow row's own clock apart from its cursor and pull counters per node

A shadow row now has two clocks. The state's own clock, stored in crdt_state, is what merges and LWW compare and what a pull hands out as the change HLC. The hlc_ts and hlc_counter columns are the cursor position pulls page by. Every writer so far kept them equal, and rows written that way read back exactly as before, so nothing moves until a writer asks for a different position through WriteFieldStateAt or WriteTombstoneAt.

Tombstone rows store their delete clock in crdt_state as well, and ReadState keeps the latest delete clock across nodes, not whichever row it happened to read last.

A counter row is pulled as one change per node it holds. The server merges an increment into whichever node's row has the newer clock, so a pull that only sent the row's own node dropped the other node's totals. That is why the Dart convergence test left device B at 7.

* fix(crdt): restamp a row's cursor when a push changes it so late stamps reach every peer

A device that was offline pushes changes stamped hours ago. The server merged them, but the row kept the old clock as its cursor position, so a peer that had already pulled past that clock never got the merged state.

Now a push that changes a row's stored state moves the row to a fresh cursor position: past the plugin clock, past every position this process handed out, past the highest one in the table, and past the row's own clock. Only the position moves. The change keeps its own clock on the wire, so you get the record you would have got by pulling earlier, and every client picks the winner it would have picked then. An LWW value that loses writes nothing. Neither does an older tombstone or a retried push.

Pulls and streams resume from cursor positions, including the multi-table window from #35. Allocation and the write share one lock per plugin, which also stops two concurrent pushes to one field from losing each other's merge.
juicycleff added a commit that referenced this pull request Oct 8, 2026
… peer (#37)

* fix(crdt): keep a shadow row's own clock apart from its cursor and pull counters per node

A shadow row now has two clocks. The state's own clock, stored in crdt_state, is what merges and LWW compare and what a pull hands out as the change HLC. The hlc_ts and hlc_counter columns are the cursor position pulls page by. Every writer so far kept them equal, and rows written that way read back exactly as before, so nothing moves until a writer asks for a different position through WriteFieldStateAt or WriteTombstoneAt.

Tombstone rows store their delete clock in crdt_state as well, and ReadState keeps the latest delete clock across nodes, not whichever row it happened to read last.

A counter row is pulled as one change per node it holds. The server merges an increment into whichever node's row has the newer clock, so a pull that only sent the row's own node dropped the other node's totals. That is why the Dart convergence test left device B at 7.

* fix(crdt): restamp a row's cursor when a push changes it so late stamps reach every peer

A device that was offline pushes changes stamped hours ago. The server merged them, but the row kept the old clock as its cursor position, so a peer that had already pulled past that clock never got the merged state.

Now a push that changes a row's stored state moves the row to a fresh cursor position: past the plugin clock, past every position this process handed out, past the highest one in the table, and past the row's own clock. Only the position moves. The change keeps its own clock on the wire, so you get the record you would have got by pulling earlier, and every client picks the winner it would have picked then. An LWW value that loses writes nothing. Neither does an older tombstone or a retried push.

Pulls and streams resume from cursor positions, including the multi-table window from #35. Allocation and the write share one lock per plugin, which also stops two concurrent pushes to one field from losing each other's merge.

* fix(crdt): cut time travel on each row's own clock, not its cursor position

ReadStateAt filtered rows on hlc_ts and hlc_counter. Since a push now
restamps the position of every row it changes, a row merged at T+10s with
its own clock at T+1s vanished from every cut before T+10s, taking all of
its field state with it. You'd ask for the record at T+5s and get no views
field at all.

We now read the record's rows and keep those whose own clock (the field
state HLC, or the tombstone's delete clock) is at or before the cut. That
is what the cut meant before restamping existed.

The in-memory shadow fake now honours a position cut when a query has one,
so the test fails on the old query instead of passing by accident.

ReadFieldHistory gets a doc note: its since, order and limit use positions
while each entry reports the row's own clock.

* fix(crdt): pull a counter row as one record carrying its full state

A counter row went out as one record per node it held. One +2 on a
counter that 500 devices had touched sent 500 records (about 89 KB) to
every client on its next pull, and the Go Syncer paid a read and a write
for each of them.

Now a counter row is one record with State set to the stored
PNCounterState, the same carrier sets, lists, text and documents already
use. Go ApplyChange, crdt-js mergeFieldState and Dart applyChange all
merge a state carrier before they look at counter_delta, so every current
client still gets every node's totals and still converges after a late
increment. The record keeps counter_delta for the row's own node, which is
what a client that predates state carriers saw before.

* fix(crdt): read each table's max cursor once per push and narrow the lock

Three smaller fixes to the push path, all under the cursor write lock.

Each changed row used to cost a maxCursor query on top of its read and its
upsert, serialized across every client by one mutex. A push now reads each
table's stored maximum once (cursorBatch). Within one process the
allocator's high mark already covers every position it handed out, so the
stored maximum only matters after a restart or for another instance, and
reading it once per push still keeps a restarted server past its old rows.

AfterMetadataWrite ran under the lock because of a deferred unlock. It now
runs after the lock is released, so a slow hook stalls only its own push.

A record delete read the whole record first, so a field row that failed to
decode failed the delete. It now reads only the record's tombstone rows.

The cursorAllocator comment now says what the lock does not cover: local
writes through AfterMutation (the lost-update guarantee is push against
push only), other instances sharing the database, and which plugin hooks
still run under it.

* fix(crdt): restamp rows the Syncer pulls so a hub's clients receive them

A hub pulls from upstream with a Syncer and serves its own clients with a
SyncController. The Syncer wrote each pulled row at its own clock, and
ordinary polling lag puts that clock behind the hub clients' cursors, so
on a hub almost any upstream row could be missed by its clients. This was
true before restamping too; it is the same bug as the late stamp.

mergeRemoteChange now does what a push does. It takes the plugin's cursor
write lock around the read, merge and write, writes nothing when the merge
leaves the state unchanged, and writes a changed row at a fresh position
from the shared allocator. A delete is written only when it is newer than
the stored one, also at a fresh position. The AfterInboundChange hook runs
after the lock is released.
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