Skip to content

Commit 1d4a0a8

Browse files
committed
fix(crdt): stop silent loss from pending and colliding CRDT writes
Loro can accept an import whose causal predecessors are missing and buffer its operations as pending, leaving the applied state untouched while reporting success; every apply path along the sync pipeline (Raft-committed apply, peer validate-and-apply, snapshot restore, rollback) now treats that as an explicit refusal instead of a no-op, so callers never advance a high-water-mark past a write that was never applied. The sync session and dispatch layers propagate this as a retryable Gap/Accepted status so the client re-pushes rather than losing the row. Separately, every collection previously shared the tenant's raw peer id, so Loro minted identical operation ids for unrelated writes in different collections; merging two of a tenant's collections into one document (as an embedded peer does) silently dropped one side of the collision. Collection state now derives its Loro peer id from `(base_peer_id, collection)`. Also splits `tenant_state::core` into `apply`, `constraints`, `rows`, and `snapshot_io` modules along these behavioural seams.
1 parent 50827c2 commit 1d4a0a8

22 files changed

Lines changed: 1116 additions & 384 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodedb-crdt/src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ pub enum CrdtError {
3535
#[error("CRDT import has too many operations: {actual} > {limit}")]
3636
ImportOperationLimitExceeded { limit: usize, actual: usize },
3737

38+
/// The imported update depends on operations this document has never seen,
39+
/// so Loro buffered them as causally pending instead of applying them.
40+
///
41+
/// The document state did NOT advance. Reporting such an import as success
42+
/// is silent data loss: the caller acknowledges a write that was never
43+
/// applied and may never be, since the missing predecessors are not part of
44+
/// this document's operation history.
45+
#[error("CRDT import depends on operations absent from this document")]
46+
ImportPendingDependencies,
47+
3848
/// A delta preview exceeded its encoded byte limit before import.
3949
#[error("delta preview exceeds byte limit: {actual} > {limit}")]
4050
PreviewDeltaTooLarge { limit: usize, actual: usize },

nodedb-crdt/src/state/snapshot.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,22 @@ impl CrdtState {
2626
///
2727
/// The limits are checked before Loro can import the blob, so rejected
2828
/// bytes never mutate this document or allocate an import graph.
29+
///
30+
/// An update whose causal predecessors are absent from this document is
31+
/// buffered by Loro as *pending* and leaves the applied state untouched.
32+
/// That is reported as [`CrdtError::ImportPendingDependencies`], never as
33+
/// success: a caller that took `Ok` here would acknowledge a write that
34+
/// was never applied. The buffered operations remain queued inside Loro,
35+
/// so a later import carrying the missing predecessors still converges.
2936
pub fn import_with_limits(&self, data: &[u8], limits: CrdtImportLimits) -> Result<()> {
3037
admit_import(data, &self.doc.oplog_vv(), limits)?;
31-
self.doc
38+
let status = self
39+
.doc
3240
.import(data)
3341
.map_err(|e| CrdtError::DeltaApplyFailed(e.to_string()))?;
42+
if status.pending.is_some() {
43+
return Err(CrdtError::ImportPendingDependencies);
44+
}
3445
Ok(())
3546
}
3647

@@ -197,6 +208,90 @@ mod tests {
197208
assert_eq!(receiver.frontier(), before);
198209
}
199210

211+
/// Loro accepts a delta whose causal predecessors are missing and buffers
212+
/// its operations as *pending*: the import call succeeds, but the applied
213+
/// state never advances and the row is never written.
214+
///
215+
/// Reporting that as a plain success is what makes the loss silent — the
216+
/// caller advances its high-water-mark and acknowledges a write that does
217+
/// not exist. An import must never report success while leaving the row it
218+
/// carried unwritten.
219+
#[test]
220+
fn import_does_not_report_success_while_operations_stay_pending() {
221+
// One source document, three writes, each exported as an incremental
222+
// delta. The middle delta is withheld, so the third depends on
223+
// operations the receiver never sees.
224+
let source = CrdtState::new(9).expect("source");
225+
226+
let v0 = source.oplog_version_vector();
227+
source
228+
.upsert("docs", "first", &[("value", LoroValue::I64(1))])
229+
.expect("first write");
230+
let delta_first = source.export_updates_since(&v0).expect("first delta");
231+
232+
source
233+
.upsert("docs", "withheld", &[("value", LoroValue::I64(2))])
234+
.expect("withheld write");
235+
236+
let v2 = source.oplog_version_vector();
237+
source
238+
.upsert("docs", "third", &[("value", LoroValue::I64(3))])
239+
.expect("third write");
240+
let delta_third = source.export_updates_since(&v2).expect("third delta");
241+
242+
let receiver = CrdtState::new(1).expect("receiver");
243+
receiver.import(&delta_first).expect("first delta applies");
244+
assert!(receiver.row_exists("docs", "first"));
245+
246+
let result = receiver.import(&delta_third);
247+
248+
// Fix-shape agnostic: the import may refuse the delta outright, or it
249+
// may report success — but it MUST NOT do the latter while the row it
250+
// carried is absent.
251+
assert!(
252+
result.is_err() || receiver.row_exists("docs", "third"),
253+
"import reported success while its operations stayed causally \
254+
pending and the row was never written"
255+
);
256+
}
257+
258+
/// The pending-operation buffer must not be observable as a silent
259+
/// frontier stall either: if an import reports success, the applied state
260+
/// must have advanced past the pre-import frontier.
261+
#[test]
262+
fn successful_import_advances_the_applied_frontier() {
263+
let source = CrdtState::new(21).expect("source");
264+
265+
let v0 = source.oplog_version_vector();
266+
source
267+
.upsert("docs", "a", &[("value", LoroValue::I64(1))])
268+
.expect("first write");
269+
let delta_a = source.export_updates_since(&v0).expect("delta a");
270+
271+
source
272+
.upsert("docs", "gap", &[("value", LoroValue::I64(2))])
273+
.expect("withheld write");
274+
275+
let v2 = source.oplog_version_vector();
276+
source
277+
.upsert("docs", "b", &[("value", LoroValue::I64(3))])
278+
.expect("second write");
279+
let delta_b = source.export_updates_since(&v2).expect("delta b");
280+
281+
let receiver = CrdtState::new(2).expect("receiver");
282+
receiver.import(&delta_a).expect("delta a applies");
283+
let before = receiver.frontier();
284+
285+
if receiver.import(&delta_b).is_ok() {
286+
assert_ne!(
287+
receiver.frontier(),
288+
before,
289+
"a successful import left the applied frontier unchanged — its \
290+
operations were buffered as pending, not applied"
291+
);
292+
}
293+
}
294+
200295
#[test]
201296
fn bounded_import_accepts_valid_delta_and_snapshot() {
202297
let delta = source_delta();

nodedb-types/src/sync/wire/ack_status.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,11 @@ pub enum AckStatus {
3030
/// A gap in the sequence was detected; `expected` is the next seq
3131
/// the server expected from this producer.
3232
Gap { expected: u64 },
33+
/// The message passed envelope validation and was admitted for
34+
/// processing, but has NOT been applied yet.
35+
///
36+
/// Emitted by the session boundary before the durable apply runs. A sender
37+
/// must keep the message queued until a terminal status arrives — treating
38+
/// this as success would retire a write that may still be refused.
39+
Accepted,
3340
}

nodedb/src/control/server/sync/async_dispatch/delta.rs

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -198,21 +198,34 @@ pub(crate) async fn apply_delta_and_finalize(
198198
// On success, rebuild the DeltaAck with the correct applied_seq and status.
199199
// The original ack_frame carries mutation_id and clock_skew_warning_ms which
200200
// we preserve; applied_seq and status come from the gate result.
201-
let gate_result = match zerompk::from_msgpack::<nodedb_types::sync::wire::SyncAckResult>(
202-
&payload,
203-
) {
204-
Ok(r) => r,
205-
Err(err) => {
206-
// Payload decode failed: fall back to the original ack frame so
207-
// the client still gets an ack (the delta was applied).
208-
warn!(
209-
collection = %delta_msg.collection,
210-
error = %err,
211-
"sync: failed to decode SyncAckResult from Data Plane; using default ack"
212-
);
213-
return Some(ack_frame);
214-
}
215-
};
201+
let gate_result =
202+
match zerompk::from_msgpack::<nodedb_types::sync::wire::SyncAckResult>(&payload) {
203+
Ok(r) => r,
204+
Err(err) => {
205+
// The Data Plane's outcome is unreadable, so whether the
206+
// delta applied is unknown. Returning the provisional ack
207+
// would assert success we cannot verify and let the client
208+
// retire the write. Refuse retryably instead: a re-push is
209+
// idempotent (the gate dedups by seq), whereas a wrongly
210+
// acked write is lost.
211+
warn!(
212+
collection = %delta_msg.collection,
213+
doc = %delta_msg.document_id,
214+
error = %err,
215+
"sync: failed to decode SyncAckResult from Data Plane; \
216+
refusing the delta rather than acking an unverified apply"
217+
);
218+
let reject = DeltaRejectMsg {
219+
mutation_id: delta_msg.mutation_id,
220+
reason: format!("apply outcome undecodable: {err}"),
221+
compensation: Some(CompensationHint::Custom {
222+
constraint: "apply_outcome_unreadable".into(),
223+
detail: "retry: the apply result could not be read".into(),
224+
}),
225+
};
226+
return SyncFrame::try_encode(SyncMessageType::DeltaReject, &reject);
227+
}
228+
};
216229

217230
// Applied-then-rejected: the delta committed and imported, but the
218231
// post-import validator flagged a constraint violation and enqueued a

nodedb/src/control/server/sync/session/delta.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,54 @@ impl SyncSession {
165165
);
166166
}
167167

168+
// Provisional: the delta has passed envelope validation and been
169+
// admitted, but the durable apply has not run yet and may still refuse
170+
// it. Claiming `Applied` here would let a sender retire a write that
171+
// never landed, so this reports `Accepted` and the caller overwrites
172+
// the status with the real outcome once the Data Plane replies.
168173
let ack = DeltaAckMsg {
169174
mutation_id: msg.mutation_id,
170175
lsn: 0,
171176
clock_skew_warning_ms,
172177
applied_seq: 0,
173-
status: nodedb_types::sync::wire::AckStatus::Applied,
178+
status: nodedb_types::sync::wire::AckStatus::Accepted,
174179
};
175180
SyncFrame::try_encode(SyncMessageType::DeltaAck, &ack)
176181
}
177182
}
178183

184+
impl SyncSession {
185+
/// Record the terminal outcome of a delta whose refusal or success was
186+
/// decided downstream of [`Self::handle_delta_push`].
187+
///
188+
/// The durable apply runs outside the session, so without this the session
189+
/// counters can only ever see admission — which is how a session that
190+
/// applied one write out of hundreds still closed reporting
191+
/// `rejected=0`. Every terminal frame returned to the client is routed
192+
/// through here so the close line reflects what actually happened.
193+
pub fn record_delta_outcome(&mut self, frame: &SyncFrame) {
194+
match frame.msg_type {
195+
SyncMessageType::DeltaReject => {
196+
self.mutations_rejected += 1;
197+
}
198+
SyncMessageType::DeltaAck => {
199+
let Some(ack) = frame.decode_body::<DeltaAckMsg>() else {
200+
// An ack we cannot read is not evidence of an apply.
201+
self.mutations_not_applied += 1;
202+
return;
203+
};
204+
match ack.status {
205+
AckStatus::Applied | AckStatus::Duplicate => self.mutations_applied += 1,
206+
AckStatus::Accepted | AckStatus::Fenced | AckStatus::Gap { .. } => {
207+
self.mutations_not_applied += 1
208+
}
209+
}
210+
}
211+
_ => {}
212+
}
213+
}
214+
}
215+
179216
/// Return `Some(skew_ms)` when the device-reported valid-time deviates
180217
/// from the Origin wall clock by more than 24 hours. Returning `None`
181218
/// inside tolerance keeps the ack payload small on the common path.

nodedb/src/control/server/sync/session/state.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,22 @@ pub struct SyncSession {
2929
pub server_clock: HashMap<String, u64>,
3030
/// Subscribed shape IDs.
3131
pub subscribed_shapes: Vec<String>,
32-
/// Mutations processed in this session.
32+
/// Mutations admitted by envelope validation in this session.
33+
///
34+
/// Admission is provisional — it says the frame was well-formed and
35+
/// authorized, NOT that it was applied. Compare against
36+
/// [`Self::mutations_applied`] to see how many actually landed.
3337
pub mutations_processed: u64,
34-
/// Mutations rejected in this session.
38+
/// Mutations the durable apply confirmed as applied.
39+
pub mutations_applied: u64,
40+
/// Mutations refused after admission — by the constraint validator, the
41+
/// Data Plane, or authorization. Counted wherever the refusal is decided,
42+
/// including refusals raised downstream of this session's provisional ack.
3543
pub mutations_rejected: u64,
44+
/// Mutations neither applied nor permanently refused: retryable refusals,
45+
/// sequence gaps, and fenced epochs. The sender is expected to re-push
46+
/// these, so they are neither successes nor dead letters.
47+
pub mutations_not_applied: u64,
3648
/// Mutations silently dropped (security rejections).
3749
pub mutations_silent_dropped: u64,
3850
/// Last activity timestamp.
@@ -84,6 +96,8 @@ impl SyncSession {
8496
server_clock: HashMap::new(),
8597
subscribed_shapes: Vec::new(),
8698
mutations_processed: 0,
99+
mutations_applied: 0,
100+
mutations_not_applied: 0,
87101
mutations_rejected: 0,
88102
mutations_silent_dropped: 0,
89103
last_activity: now,

nodedb/src/control/server/sync/session/tests.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,42 @@ fn crc32c_mismatch_rejects_delta() {
375375
assert_eq!(r2.unwrap().msg_type, SyncMessageType::DeltaReject);
376376
assert_eq!(session.mutations_rejected, 1);
377377
}
378+
379+
/// The session emits its `DeltaAck` before the delta has been dispatched to
380+
/// the Data Plane, and stamps it `AckStatus::Applied`. Nothing has been
381+
/// applied at that point — the durable apply happens afterwards, and may be
382+
/// refused. If the connection drops in that window, or the caller forwards the
383+
/// provisional frame, the client records a write that does not exist.
384+
///
385+
/// An acknowledgement must never claim `Applied` before an apply has occurred.
386+
#[test]
387+
fn provisional_delta_ack_does_not_claim_applied() {
388+
let mut session = make_authenticated_session();
389+
390+
let data = serde_json::json!({"status": "active"});
391+
let msg = DeltaPushMsg {
392+
collection: "orders".into(),
393+
document_id: "o1".into(),
394+
delta: nodedb_types::json_to_msgpack(&data).unwrap(),
395+
peer_id: 1,
396+
mutation_id: 42,
397+
checksum: 0,
398+
device_valid_time_ms: None,
399+
producer_id: 0,
400+
epoch: 0,
401+
seq: 0,
402+
};
403+
404+
let frame = session
405+
.handle_delta_push(&msg, None, None, None)
406+
.expect("an accepted push returns a frame");
407+
assert_eq!(frame.msg_type, SyncMessageType::DeltaAck);
408+
409+
let ack: DeltaAckMsg = frame.decode_body().expect("ack body decodes");
410+
assert_ne!(
411+
ack.status,
412+
nodedb_types::sync::wire::AckStatus::Applied,
413+
"the session acknowledged a delta as Applied before it was dispatched \
414+
to the Data Plane, let alone applied"
415+
);
416+
}

nodedb/src/control/server/sync/session_handler/session_loop.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,18 @@ pub(in crate::control::server::sync) async fn handle_sync_session(
295295
Some(response)
296296
};
297297

298-
if let Some(r) = final_response
299-
&& ws.send(Message::Binary(r.to_bytes().into())).await.is_err()
300-
{
301-
break;
298+
if let Some(r) = final_response {
299+
// Account for the terminal outcome before the frame
300+
// leaves: refusals decided downstream of the
301+
// session's provisional ack are invisible to the
302+
// session otherwise, which is what let a lossy
303+
// session close reporting zero rejections.
304+
if delta_msg.is_some() {
305+
session.record_delta_outcome(&r);
306+
}
307+
if ws.send(Message::Binary(r.to_bytes().into())).await.is_err() {
308+
break;
309+
}
302310
}
303311
}
304312
}
@@ -491,8 +499,10 @@ pub(in crate::control::server::sync) async fn handle_sync_session(
491499

492500
info!(
493501
session = %session_id,
494-
mutations = session.mutations_processed,
502+
admitted = session.mutations_processed,
503+
applied = session.mutations_applied,
495504
rejected = session.mutations_rejected,
505+
not_applied = session.mutations_not_applied,
496506
silent_dropped = session.mutations_silent_dropped,
497507
uptime_secs = session.uptime_secs(),
498508
"sync: session closed"

0 commit comments

Comments
 (0)