serverpod-offline-sync/docs/outbound-collection-consistency.md
2026-09-14 13:39:07 -03:00

5.8 KiB

Outbound collection consistency (follow-up)

Summary

Outbound change collection reads CRDT metadata and the domain data it describes in separate, non-atomic queries. Under concurrent domain writes — real on the server, where co-members of a shared space write while a sync runs — this race can make an otherwise-healthy stream sync fail and record a durable integrity violation for a condition that the very next round would resolve on its own. Failing the session for a transient, self-correcting race is the design weakness to fix; no data is ever applied incorrectly, so this is a robustness/liveness problem, not a corruption one.

This is not yet implemented — it is tracked design debt. The single-pass multi-space collection (collectPendingChanges) did not introduce the race but widened its window, which is what surfaced it.

The mechanism

Collection is snapshot-then-fetch, and lock-free by design (the per-space transactionForUser lock guards the inbound merge, not outbound collection):

  1. _streamInserts / _streamUpdates / _streamDeletes read the CRDT metadata (CrdtDataRow / CrdtDataField / tombstones) as a query result — a snapshot at some point t0.
  2. The domain row/column values are then read in later, separate queries (_fetchDomainRow, _fetchOwnedColumnValue, _readDomainRowOwner).

Between (1) and (2), and across the three independently-snapshotted queries, the domain can change underneath the collection.

Failure modes

  1. Concurrent delete → false-positive missingDomainRow. A row whose CRDT insert metadata was snapshotted, but whose domain row is deleted before the domain read, makes _fetchDomainRow miss. Today that records a durable missingDomainRow violation and fails the session — even though the next round would simply collect the tombstone and converge.

  2. FK target inserted after the insert snapshot → dangling reference. The insert snapshot is taken at t0, but FK column values are read at fetch time (later). If a referencing row's FK points to row B that was inserted after t0, then B is not in the collected insert batch, yet the reference to it is shipped. The same applies to an FK update collected in the updates pass whose target was inserted after the inserts pass: the edge is sent without the node. The peer then either churns through FK projection (SET NULL / SET DEFAULT / RESTRICT with the durable attemptedValue) until a later round delivers B, or trips an integrity violation and fails. Either way, a benign ordering skew degrades the stream.

  3. Value ahead of its HLC (self-correcting, listed for completeness). If a column is bumped from HLC h to h2 between the metadata read and the domain read, the batch ships (value@h2, HLC=h). This converges: our own CrdtDataField is now at h2, our send checkpoint only advanced to h, so the next round re-ships (value@h2, HLC=h2) and every replica ratifies it. No permanent divergence — but it shares the same root cause as (1) and (2).

Why "fail the session" is the wrong response

All three are transient and self-correcting: the next collection round picks up the tombstone, the late insert, or the higher HLC, and replicas converge. Tearing down the stream — and persisting an integrity-violation record that reads like corruption — for a race that resolves itself next round converts an ordering skew into an outage plus a misleading audit trail. A durable offline_sync_integrity_violations entry should mean a genuine invariant breach (an ownership collision, real corruption), not "two reads happened to straddle a concurrent commit."

Directions

  • Read a consistent snapshot (primary). Run the whole collection — the three metadata queries and the domain reads — inside one read-only, snapshot-consistent transaction (repeatable-read). On Postgres this is MVCC: a read snapshot, not a write lock, so it adds no cross-space write contention (and is therefore the right answer to "do we have to lock all spaces?" — no, we take a snapshot, not a lock). A single snapshot closes the race outright:

    • The delete in (1) is invisible if it commits after the snapshot, and fully visible (metadata + domain) if before — never half-seen.
    • It makes each batch referentially closed by construction: the domain's own FK constraint guarantees that wherever A.fk = B is visible in the snapshot, B is too, so B is collected alongside A. (2) cannot happen.
  • Tolerate, don't fail (fallback / defense in depth). Where a consistent snapshot is impractical, distinguish a transient missing row / missing referent (present in CRDT metadata, not yet consistently readable, or arriving next round) from a true violation, and defer the racy change to the next round instead of failing the session and recording a durable violation. The peer's FK projection already tolerates a transiently-missing referent — the collector should match that posture rather than failing on it.

  • Shrink the window. Independent of the above, replace row-by-row domain reads with set-based queries (WHERE id IN (…)) per table, narrowing the gap between the metadata snapshot and the domain reads. Cheap, and reduces exposure even before a full snapshot lands.

Priority

Correctness is preserved today (convergence + fail-safe: no wrong data is ever applied), so this does not block current use. But because it can fail a healthy long-lived continuous sync under concurrent shared-space writes — the exact workload shared spaces enable — it should be addressed before such deployments are relied upon. The consistent-snapshot approach is the smallest change that removes all three failure modes at once.