Skip to content

lite→Origin document sync: pushed CRDT deltas are acked but never materialize into a shape-servable collection (ShapeSnapshot doc_count=0) #146

Description

@emanzx

Summary

A Lite replica can push document CRDT deltas to Origin and they are accepted (acked, server LSN advances, a CRDT checkpoint is persisted) — but the data never becomes servable: a fresh Lite shape-subscribing the same collection gets an initial ShapeSnapshot with doc_count=0. So the lite->Origin push path works, but the Origin->lite serve path returns nothing. The round-trip (write on one replica -> read on another via Origin) does not complete.

Repro

  1. Origin running (trust auth), sync WS on :9090.
  2. From a Lite replica, write ~1,537 documents into collection entries with sync enabled. Observe in the Lite log: pushed deltas to Origin, delta acknowledged rtt_ms~8, FtsIndexAck ... accepted=true for every doc.
  3. From a fresh Lite replica, ShapeSubscribe { ShapeType::Document { collection: "entries", predicate: [] } }.

Observed: ShapeSnapshot { doc_count: 0, snapshot_lsn: 3577, data: [1 byte] } — identical for tenant_id 0, 1, and 2.

Evidence the pushes were received

  • Origin snapshot_lsn = 3577 (advanced from the pushes).
  • /data/<inst>/crdt-ckpt/tenant-tenant:0.ckpt ~= 20 MB, written right after the sync.
  • Every delta was acked back to the pusher.

Evidence the collection is not served

  • SHOW COLLECTIONS on Origin -> 0 rows. SELECT ... FROM entries -> "table not found".
  • Round-trip ShapeSnapshot.doc_count = 0 in all tenants.

Is this expected / known?

The repo's own interop test acknowledges the serve side is stubbed — nodedb-lite/tests/sync_interop_shape.rs:137:

"Origin does not currently fan-out ShapeDelta back over the same connection as the delta pusher. We simulate Origin's fan-out by directly feeding a ShapeDelta frame through the SyncClient handler..."

Two questions:

  1. Is Origin-side materialization of pushed document deltas into a servable collection implemented for 0.1.0, or known-unimplemented?
  2. Is the intended flow that the collection must be created on Origin first (DDL) before Lite pushes/subscribes? If so it isn't documented; if not, pushed deltas aren't being materialized.

Scope note

The CRDT protocol itself is solid — handshake, real Loro delta push, ack, reconnect, and the sync_interop_live suite all pass against a real Origin. This is specifically about the serve/materialize half of lite<->Origin document sync.

Activity

  1. emanzx commented on Jun 15, 2026

    @emanzx
    ContributorAuthor

    Note on layer scope, since pagedb#6 was just fixed with a related-sounding symptom:

    NodeDB-Lab/pagedb#6's fix #2 ("incremental apply never advanced the trees — applied segments and key/value data were unreachable on the follower even after promotion") rhymes with this issue's symptom (pushed deltas accepted/promoted but unreachable on read). But I checked: nodedb (Origin) does not depend on pagedb — that fix is lite-side. So it does not carry over to this Origin-side document-serve gap; this remains open.

    Flagging it anyway in case the same class of bug exists in the Origin's own materialization path — i.e. pushed document CRDT deltas are committed/checkpointed but the target collection root the shape snapshot reads from is never advanced, so ShapeSnapshot.doc_count stays 0. If the Origin's snapshot/materialize path has an analogous "never advanced the target root" defect, the fix shape may mirror pagedb#6's.

    Still reproduces on current main (Origin LSN advances to 3577 from the pushes, 20MB crdt-ckpt persisted, but shape round-trip = 0 docs in all tenants).

  2. farhan-syah commented on Jul 2, 2026

    @farhan-syah
    Member

    Resolved. Root cause was two distinct gaps in the lite→Origin document sync path; both are now fixed and proven by a cross-repo end-to-end test.

    1. Collection was never registered on Origin. A collection created only on lite pushed CRDT deltas, but Origin had no catalog entry for it, so the deltas had nowhere to land as a shape-servable collection. Fixed by a standardized collection-schema announce: lite now emits a CollectionSchema frame (opcode 0x13) before the first delta for a collection, and Origin materializes it create-only via PutCollectionIfAbsent.

    2. Synced CRDT deltas were acked but never materialized into the queryable document store. Even once registered, applying a Loro delta on Origin updated CRDT state (and the FTS index, delivered via a separate frame) but never wrote the document into the sparse DOCUMENTS btree that DocumentScan/ShapeSnapshot read — so a plain scan and the shape snapshot returned 0. Fixed on the Data Plane apply path: after a clean CRDT apply, the merged row is now materialized into the sparse document store (keyed by surrogate, matching the native put path; bitemporal-aware), making the collection shape-servable.

    Proof (E2E, no Origin pre-create): a schemaless document collection created only on lite, written to, and synced now becomes visible in Origin's pg_class, is returned by both text_match and a plain SELECT id, and yields a non-zero ShapeSnapshot document set.

    Follow-ups tracked separately (out of scope here): the synced-doc apply path does not yet emit CDC WriteEvents, update column stats, or maintain secondary/vector/spatial indexes the way the native write path does.

  3. emanzx commented on Jul 2, 2026

    @emanzx
    ContributorAuthor

    Re-ran this end-to-end against main (incl. 3a06321e) under mae8's real workload, and the fix definitely moved the needle: the collection now registers and the round-trip returns non-zero, exactly as your E2E shows. But it lands at 1, not N — only the first document of a synced collection becomes servable. So the literal doc_count=0 is gone, but the round-trip still doesn't carry the full set. Reopening since it's the same round-trip, just one layer deeper.

    Repro

    1. Origin on main (trust auth), sync WS :9090.
    2. From a Lite replica, register collection entries via create_collection (so it registers cleanly — see the companion nodedb-lite announce issue) and write N > 1 distinct documents with sync enabled. Throttle to stay under the sync rate limit so nothing is dropped.
    3. On Origin: SELECT count(*) FROM entries; → 1. SELECT id FROM entries; → only the first-written doc. A ShapeSnapshot returns a 1-document set, not N.

    Measured at N = 5 / 300 / 1000 — deterministic, always the first-written doc.

    What I ruled out (so this isn't a repro artifact)

    • Not the client write path. The local Lite replica holds all N distinct docs (local scan), and Origin's own logs show each delta arriving with a unique per-document UUID (collection=entries doc=019f…).
    • Not delta loss. Throttled run: session close mutations=15 rejected=0 silent_dropped=0 for N=5; all applied cleanly, none rejected/fenced.
    • Not bitemporal-specific. Announcing the collection as non-bitemporal (so the merged row goes through the plain put, not versioned_put) still yields count=1.
    • Not a materialize-lag. Waited through multiple 30s clone-materializer sweeps; stays at 1.

    The sparse store does grow during the run (core-N.redb grows, CRDT engines checkpointed … dirty_pages=955), so bytes are landing — but only the first document surfaces to DocumentScan/ShapeSnapshot.

    Where I think it lives (your call to confirm)

    Data-Plane apply materialize: data/executor/handlers/control/crdt.rs::execute_crdt_apply → crdt_materialize.rs::{encode_crdt_row, materialize_synced_document}. The applies report Clean and each carries a unique surrogate, so the gate (Clean && surrogate != ZERO) should fire per doc. Working hypothesis: the Lite deltas are incremental Loro updates (ExportMode::updates(&version_before)), and when applied independently to Origin's Loro replica, engine.read_row(collection, document_id) only resolves the first document into the merged map — so encode_crdt_row returns None for docs 2..N and they never reach the sparse btree. If that's right, the fix shape is on the Origin's incremental-apply → row-reconstruction, not the materialize write itself.

    Happy to hand over the exact harness + logs (it's mae8's bruteforce_origin_sync test).

  4. added
    type:bugA defect — broken, incorrect, or lost data
    sev:1-criticalData loss, corruption, security, or crash; no workaround
    engine:documentDocument engine (schemaless + strict)
    sev:2-highMajor functionality broken; no acceptable workaround
    and removed
    sev:1-criticalData loss, corruption, security, or crash; no workaround
    on Jul 8, 2026
  5. farhan-syah commented on Jul 19, 2026

    @farhan-syah
    Member

    Reopening to investigate under v0.4.0 — the round-trip now returns non-zero but caps at 1 (only the first-written doc surfaces). Digging into the Origin incremental-apply → row-reconstruction path (crdt_materialize.rs::{encode_crdt_row,materialize_synced_document}) as you pointed to; will report root cause + fix.

  6. farhan-syah commented on Jul 20, 2026

    @farhan-syah
    Member

    Fixed on main in 222ad32.

    Root cause: Origin acked pushed CRDT deltas but never materialized them into a servable collection, so DocumentScan / ShapeSnapshot reported doc_count=0.

    Fix: crdt_materialize.rs materializes applied DocUpsert / DocDelete deltas into the sparse DOCUMENTS store via the native put path, so scans and shape snapshots observe them end-to-end.

    Coverage: document_collection_registers_on_origin_via_announce (programmatic path) plus new SQL-DDL round-trip coverage — create → sync → Origin serves all rows via the exact entry point from the report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:crdt-syncCRDT, edge-to-cloud syncengine:documentDocument engine (schemaless + strict)sev:2-highMajor functionality broken; no acceptable workaroundtype:bugA defect — broken, incorrect, or lost data

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions