Skip to content

Importing a shallow snapshot into a doc with concurrent ops stalls as permanently pending when the snapshot root is a single-head frontier (multi-head root imports fine) #1040

Description

@vasiliytsykin

Environment

  • loro-crdt 1.13.6 (JS; reproduces identically with the nodejs and bundler builds)
  • Node.js v22.13.1, also reproduced inside Cloudflare workerd

Summary

Importing a shallow snapshot into a document that already has any operation concurrent to the snapshot's root silently buffers the whole import as pendingtoJSON() stays empty. The reported missing dependency is the snapshot's own root boundary op, i.e. content that is contained in the very snapshot being imported; nothing short of re-receiving the same history as a full mode: "update" export unblocks it. The same snapshot bytes import fine into a pristine (no local ops) document.

Surprisingly, the behavior depends on the shape of the frontier the snapshot is rooted at:

Importer state Shallow root: single-head frontier Shallow root: multi-head frontier
pristine doc (no ops) ✅ applies ✅ applies
doc with one concurrent commit ❌ stuck pending forever ✅ applies

If the single-head case is intended (the docs say updates concurrent to the shallow start version cannot be imported), the multi-head case succeeding looks inconsistent — and either way the failure mode is a silent permanent pending rather than an error, which is very hard to detect.

Minimal reproduction

// node --input-type=module repro.mjs   (loro-crdt 1.13.6)
import { LoroDoc } from "loro-crdt";
const pend = (st) => (st.pending === null ? "null" : JSON.stringify([...st.pending.entries()]));
const freshWithCommit = (peer) => {
  // e.g. every new client of a sync system does its own "schema init" commit
  const d = new LoroDoc();
  d.setPeerId(peer);
  d.getMap("meta").set("schemaVersion", 1);
  d.commit();
  return d;
};

// ── Case 1: SINGLE-head root — client has SEEN server history, edits on top ──
const server1 = new LoroDoc();
server1.setPeerId(1);
server1.getMap("meta").set("schemaVersion", 1);
server1.commit();
const client1 = new LoroDoc();
client1.setPeerId(2);
client1.import(server1.export({ mode: "update" })); // client knows server history
client1.getTree("nodes").createNode();
client1.commit();
server1.import(client1.export({ mode: "update" }));
console.log("frontiers:", JSON.stringify(server1.frontiers())); // ONE head: [{peer:"2",counter:0}]
const shallowSingle = server1.export({ mode: "shallow-snapshot", frontiers: server1.frontiers() });

const f1 = freshWithCommit(3);
const st1 = f1.import(shallowSingle);
console.log("single-head → nodes:", f1.getTree("nodes").getNodes().length, "| pending:", pend(st1));
// single-head → nodes: 0 | pending: [["2",{"start":0,"end":1}]]   ← stuck; the "missing" op
//   is the snapshot's own head (peer 2), which is inside the snapshot being imported

const pristine = new LoroDoc();
pristine.setPeerId(4);
console.log("pristine   → nodes:", pristine.import(shallowSingle).pending === null
  ? pristine.getTree("nodes").getNodes().length : "pending");
// pristine   → nodes: 1                                            ← same bytes import fine

// ── Case 2: MULTI-head root — client history is CONCURRENT to the server commit ──
const server2 = new LoroDoc();
server2.setPeerId(5);
server2.getMap("meta").set("schemaVersion", 1);
server2.commit();
const client2 = new LoroDoc();
client2.setPeerId(6);
client2.getMap("meta").set("schemaVersion", 1); // concurrent, never saw server
client2.commit();
client2.getTree("nodes").createNode();
client2.commit();
server2.import(client2.export({ mode: "update" }));
console.log("frontiers:", JSON.stringify(server2.frontiers())); // TWO heads
const shallowMulti = server2.export({ mode: "shallow-snapshot", frontiers: server2.frontiers() });

const f2 = freshWithCommit(7);
const st2 = f2.import(shallowMulti);
console.log("multi-head → nodes:", f2.getTree("nodes").getNodes().length, "| pending:", pend(st2));
// multi-head → nodes: 1 | pending: null                            ← works

Observed output (Node v22.13.1, loro-crdt 1.13.6):

frontiers: [{"peer":"2","counter":0}]
single-head → nodes: 0 | pending: [["2",{"start":0,"end":1}]]
pristine   → nodes: 1
frontiers: [{"peer":"6","counter":1},{"peer":"5","counter":0}]
multi-head → nodes: 1 | pending: null

Real-world impact

A sync server that answers a new client's handshake with export({ mode: "shallow-snapshot", frontiers: doc.frontiers() }) silently delivers an empty document to any client that has already made a single local commit (e.g. schema-version initialization) whenever the workspace frontier has one head — which is the common case in a live workspace (the last writer's op causally dominates the history). Data is intact on the server; the client just never applies the payload and nothing reports an error.

Our workaround: before sending a shallow snapshot, the server probes it by importing into a scratch doc that mimics a fresh client (one local commit) and falls back to export({ mode: "update" }) when the probe import returns non-null pending.

Questions

  1. Is the single-head behavior intended? If yes, could import() surface it distinctly (error or a dedicated status) instead of a silent permanent pending, and could the docs state that a shallow snapshot rooted at a single-head frontier is only importable into pristine docs?
  2. Why does a multi-head root import cleanly into a doc with concurrent ops? If that is the intended merge path, could the single-head case behave the same?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions