Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
153 lines
7.7 KiB
Markdown
153 lines
7.7 KiB
Markdown
# Convergent Sync CRDT Core
|
|
|
|
Status: experimental core; not connected to persistence or cloud providers yet.
|
|
|
|
Issue: [#2245](https://github.com/binaricat/Netcatty/issues/2245)
|
|
|
|
## Goal
|
|
|
|
The existing sync engine compares local and remote snapshots against a stored
|
|
base. That is useful for two replicas, but folding more replicas or providers in
|
|
different orders is not algebraically safe. The v2 core defines a state-based
|
|
join so every replica reaches the same state regardless of message order,
|
|
duplication, or grouping.
|
|
|
|
This first change intentionally contains only pure domain logic. Encryption,
|
|
legacy migration, provider verification, persistence, and UI are separate
|
|
follow-up changes after this core is reviewed.
|
|
|
|
Mutation callers must provide the wall-clock sample used to advance the HLC.
|
|
The domain layer never reads `Date.now()`, so replaying the same state, device,
|
|
mutation batch, and timestamp produces identical serialized state.
|
|
|
|
## State model
|
|
|
|
Each device owns a monotonically increasing counter. A write allocates a unique
|
|
dot `(deviceId, counter)`. The global version vector allocates collision-free
|
|
dots, while each candidate records the exact prior dots observed in its own
|
|
register. The register context is an exact dot set rather than another compact
|
|
version vector because one device's global counters can interleave writes to
|
|
different registers. A Hybrid Logical Clock (HLC) supplies a user-facing
|
|
ordering hint without defining causality.
|
|
|
|
The replica contains:
|
|
|
|
- one global dotted version vector and HLC;
|
|
- a compact dot-origin index mapping every device counter to its register;
|
|
- an MV-register for entity presence;
|
|
- an MV-register for collection position;
|
|
- an MV-register for every top-level entity field;
|
|
- an MV-register for every settings leaf path (arrays are atomic leaves);
|
|
- observed-remove string entries with their own presence and position
|
|
registers.
|
|
|
|
A string-entry remove is emitted only after the replica has observed a currently
|
|
visible add. Deleting a locally absent entry is a no-op, so a concurrent add on
|
|
another replica survives without creating an artificial value/tombstone conflict.
|
|
|
|
Mutation application is idempotent for already-selected entity fields, string
|
|
entries, and settings. A same-value settings write still tombstones active
|
|
ancestor or descendant paths, and a same-value write against an MV-register
|
|
conflict still emits a causally dominating resolution.
|
|
|
|
A full entity upsert follows the same rule for presence, fields, and collection
|
|
position: unchanged conflict-free registers are preserved, while accepting a
|
|
currently selected conflicted value emits a new candidate that dominates every
|
|
retained alternative.
|
|
|
|
String-entry add mutations likewise resolve visible presence and position
|
|
conflicts even when the selected values are unchanged; conflict-free repeated
|
|
adds remain no-ops.
|
|
|
|
Deletion is a register candidate, not absence from the serialized structure.
|
|
Tombstones are retained indefinitely in v2. A later recreation replaces a
|
|
tombstone only when its new dot causally observes the deletion.
|
|
|
|
Settings writes keep the active leaf set prefix-free. Replacing an object leaf
|
|
with an atomic parent (or the reverse) causally tombstones the overlapping
|
|
paths. Deleting a settings path tombstones that path and every causally observed
|
|
descendant, so deleting a subtree cannot leave stale leaf registers visible;
|
|
deleting a nested path does not implicitly remove an atomic ancestor.
|
|
Independent replicas can still create a parent/descendant shape conflict;
|
|
materialization then selects a deterministic maximal prefix-free set, keeps
|
|
non-overlapping siblings, and reports the competing paths and candidates for
|
|
explicit resolution.
|
|
|
|
Entity field updates also write a fresh present candidate. Consequently, an
|
|
offline deletion racing an offline edit becomes a presence conflict; it cannot
|
|
silently hide the edit. No-op field writes allocate no dot and do not refresh
|
|
presence. Deleting a field from a non-present entity may tombstone stale field
|
|
data but never recreates the entity.
|
|
|
|
## Join
|
|
|
|
For each register, the join keeps:
|
|
|
|
1. candidates present on both sides;
|
|
2. left-only candidates not covered by the right register's causal context;
|
|
3. right-only candidates not covered by the left register's causal context.
|
|
|
|
Candidates causally dominated by another surviving candidate are removed. The
|
|
replica vector is the pointwise maximum. A global vector is never used as proof
|
|
that a candidate from an absent register was superseded; only a candidate in
|
|
that same register can carry such proof. This makes partial provider states
|
|
fail validation instead of silently deleting unrelated local data. Join remains
|
|
commutative, associative, and idempotent. Property tests exercise those laws
|
|
directly and also reduce 2-20 randomly generated offline replicas using
|
|
reordered, partitioned, and duplicated joins.
|
|
|
|
Reusing a dot for different data or different register addresses is an
|
|
invariant violation and fails closed. Every global vector counter must also be
|
|
witnessed by a retained candidate dot or same-register candidate context.
|
|
Hydration rejects dangling observations so a malformed or partial state cannot
|
|
use an unsubstantiated vector to discard local candidates during join. It also
|
|
rejects a context that references any currently retained candidate dot: exact
|
|
contexts contain dominated history only, so retained references would represent
|
|
invalid causal dominance or a cycle. The permanent dot-origin index proves that
|
|
each candidate and context dot belongs to the register claiming it, so copying
|
|
an omitted register's dot into unrelated context cannot satisfy validation.
|
|
Origin indexes join by dot and reject conflicting register identities.
|
|
|
|
## Materialization and conflicts
|
|
|
|
Concurrent candidates remain in the CRDT state. A deterministic materialized
|
|
snapshot is selected for legacy readers and immediate application:
|
|
|
|
1. a value sorts after a tombstone;
|
|
2. then HLC wall time and logical counter;
|
|
3. then device ID using locale-independent UTF-16 code-unit order;
|
|
4. then device counter.
|
|
|
|
Candidate ordering in canonical serialization uses the dot, not the selected
|
|
winner order. Dot and HLC objects are rebuilt with fixed property order so
|
|
provider JSON key ordering cannot change identity or serialized bytes.
|
|
Conflicts are emitted in collection, entity, field-path, and dot order.
|
|
Resolving a conflict creates a new write whose causal context covers all
|
|
observed candidates, so the resolution remains stable when stale replicas
|
|
return.
|
|
|
|
Internal field and position conflicts are emitted only while their parent
|
|
entity or string entry is materialized. An accepted parent deletion retains
|
|
the underlying causal metadata for future explicit recreation but suppresses
|
|
non-actionable child conflicts from the current conflict list. A concurrent
|
|
delete/update whose selected presence remains visible still exposes both the
|
|
presence conflict and all active internal conflicts.
|
|
|
|
## Complexity
|
|
|
|
State validation and canonical serialization are linear in registers,
|
|
candidates, and retained context dots. Join additionally compares the bounded
|
|
set of concurrent candidates within each register. Sorting is bounded by keys
|
|
within each map. Batch mutation clones the replica once, avoiding a full-state
|
|
copy per imported entity. `npm run bench:sync-crdt` reports non-gating
|
|
measurements for 1,000, 5,000, and 10,000 entities so accidental quadratic
|
|
behavior is visible during review.
|
|
|
|
## Follow-up boundaries
|
|
|
|
The encrypted v2 envelope, legacy baselines, migration preview, protection
|
|
snapshots, key rotation, and fail-closed protocol rules are described in
|
|
[`convergent-sync-protocol-v2.md`](./convergent-sync-protocol-v2.md). The final
|
|
change will integrate provider read-merge-write-verify loops, multi-window
|
|
locking, conflict resolution state, and localized settings UI.
|