Skip to content

Commit 34bf32d

Browse files
authored
fix(crdt): materialize synced deltas into sparse document store (#147)
CRDT sync applies were merging deltas into the Loro engine but never writing the resulting document into the sparse document store, so DocumentScan and ShapeSnapshot could not observe synced writes. Read the merged row back on a Clean apply, encode it into the canonical schemaless storage bytes, and put it into the sparse store under the surrogate-derived key (versioned for bitemporal collections). A materialization failure is logged and swallowed rather than failing the apply, since the sync stream must not wedge on it.
1 parent 676df00 commit 34bf32d

1 file changed

Lines changed: 155 additions & 31 deletions

File tree

  • nodedb/src/data/executor/handlers/control

nodedb/src/data/executor/handlers/control/crdt.rs

Lines changed: 155 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ use nodedb_types::sync::wire::{AckStatus, SyncProvenance};
1111

1212
use crate::bridge::envelope::{ErrorCode, Response};
1313
use crate::data::executor::sync_gate::SyncAdmit;
14-
use crate::engine::crdt::tenant_state::ValidatedApplyOutcome;
14+
use crate::engine::crdt::tenant_state::{TenantCrdtEngine, ValidatedApplyOutcome};
1515

1616
use crate::data::executor::core_loop::CoreLoop;
17+
use crate::data::executor::doc_format::canonicalize_document_for_storage;
1718
use crate::data::executor::task::ExecutionTask;
19+
use crate::engine::document::crdt_store::loro_value_to_json;
20+
use crate::engine::document::store::surrogate_to_doc_id;
21+
use crate::engine::sparse::btree_versioned::VersionedPut;
1822

1923
impl CoreLoop {
2024
pub(in crate::data::executor) fn execute_crdt_read(
@@ -225,33 +229,58 @@ impl CoreLoop {
225229
// Non-sync path (SQL / native client): validate + apply, no gate.
226230
// There is no client to reject here, so the validated outcome is
227231
// only observed for its DLQ side effect and logged.
228-
let engine = match self.get_crdt_engine(tenant_id) {
229-
Ok(e) => e,
230-
Err(e) => {
231-
warn!(core = self.core_id, error = %e, "failed to create CRDT engine");
232-
return self.response_error(
233-
task,
234-
ErrorCode::Internal {
235-
detail: e.to_string(),
236-
},
237-
);
232+
// Borrow the engine in a nested block so the &mut borrow is dropped
233+
// before the sparse write below takes &self. On a Clean apply we
234+
// read the merged row back and encode it while the borrow is live,
235+
// carrying the materialized bytes out.
236+
let materialized = {
237+
let engine = match self.get_crdt_engine(tenant_id) {
238+
Ok(e) => e,
239+
Err(e) => {
240+
warn!(core = self.core_id, error = %e, "failed to create CRDT engine");
241+
return self.response_error(
242+
task,
243+
ErrorCode::Internal {
244+
detail: e.to_string(),
245+
},
246+
);
247+
}
248+
};
249+
let outcome = engine.apply_committed_delta_validated(
250+
collection,
251+
delta,
252+
surrogate,
253+
document_id,
254+
peer_id,
255+
);
256+
match outcome {
257+
ValidatedApplyOutcome::Clean => {
258+
if surrogate != Surrogate::ZERO {
259+
Self::encode_crdt_row(engine, collection, document_id)
260+
} else {
261+
None
262+
}
263+
}
264+
ValidatedApplyOutcome::Rejected(vt) => {
265+
debug!(core = self.core_id, %collection, reason = %vt, "crdt apply violated constraint (DLQ)");
266+
None
267+
}
268+
ValidatedApplyOutcome::Malformed => {
269+
warn!(core = self.core_id, %collection, "crdt apply skipped malformed delta");
270+
None
271+
}
238272
}
239273
};
240-
let outcome = engine.apply_committed_delta_validated(
241-
collection,
242-
delta,
243-
surrogate,
244-
document_id,
245-
peer_id,
246-
);
247-
match outcome {
248-
ValidatedApplyOutcome::Clean => {}
249-
ValidatedApplyOutcome::Rejected(vt) => {
250-
debug!(core = self.core_id, %collection, reason = %vt, "crdt apply violated constraint (DLQ)");
251-
}
252-
ValidatedApplyOutcome::Malformed => {
253-
warn!(core = self.core_id, %collection, "crdt apply skipped malformed delta");
254-
}
274+
// engine borrow dropped here; materialize into the sparse document
275+
// store so DocumentScan / ShapeSnapshot see the synced document.
276+
if let Some(bytes) = materialized {
277+
self.materialize_synced_document(
278+
task.request.database_id.as_u64(),
279+
tenant_id.as_u64(),
280+
collection,
281+
surrogate,
282+
&bytes,
283+
);
255284
}
256285
self.checkpoint_coordinator.mark_dirty("crdt", 1);
257286
return self.response_ok(task);
@@ -284,7 +313,7 @@ impl CoreLoop {
284313
Pending { installed: u64 },
285314
Applied(ValidatedApplyOutcome),
286315
}
287-
let outcome = {
316+
let (outcome, materialized) = {
288317
let engine = match self.get_crdt_engine(tenant_id) {
289318
Ok(e) => e,
290319
Err(e) => {
@@ -299,19 +328,30 @@ impl CoreLoop {
299328
};
300329
let installed = engine.installed_constraint_version(collection);
301330
if constraint_version_required > installed {
302-
GateOutcome::Pending { installed }
331+
(GateOutcome::Pending { installed }, None)
303332
} else {
304-
GateOutcome::Applied(engine.apply_committed_delta_validated(
333+
let applied = engine.apply_committed_delta_validated(
305334
collection,
306335
delta,
307336
surrogate,
308337
document_id,
309338
peer_id,
310-
))
339+
);
340+
// On a Clean apply, read the merged row back and encode
341+
// it while the engine borrow is still live so the bytes
342+
// can be materialized into the sparse store below.
343+
let mat = if matches!(applied, ValidatedApplyOutcome::Clean)
344+
&& surrogate != Surrogate::ZERO
345+
{
346+
Self::encode_crdt_row(engine, collection, document_id)
347+
} else {
348+
None
349+
};
350+
(GateOutcome::Applied(applied), mat)
311351
}
312352
};
313353
// engine borrow is dropped here; mark_dirty / sync_commit take
314-
// &mut self.
354+
// &mut self, and the sparse materialize takes &self.
315355
let reject = match outcome {
316356
GateOutcome::Pending { installed } => {
317357
// Create-race: the constraints this delta was admitted
@@ -348,6 +388,18 @@ impl CoreLoop {
348388
None
349389
}
350390
};
391+
// Materialize the merged document into the sparse store so
392+
// DocumentScan / ShapeSnapshot see the synced write. `materialized`
393+
// is Some only on a Clean apply with an assigned surrogate.
394+
if let Some(bytes) = materialized {
395+
self.materialize_synced_document(
396+
task.request.database_id.as_u64(),
397+
tenant_id.as_u64(),
398+
collection,
399+
surrogate,
400+
&bytes,
401+
);
402+
}
351403
// Advance the HWM unconditionally after apply — a rejected,
352404
// fenced, or malformed delta must not wedge the sync stream.
353405
self.sync_commit(prov);
@@ -416,4 +468,76 @@ impl CoreLoop {
416468
) -> u64 {
417469
*self.sync_hwm.get(&(producer_id, stream_id)).unwrap_or(&0)
418470
}
471+
472+
/// Read the merged Loro row back and encode it into the canonical
473+
/// schemaless storage bytes the native put path writes.
474+
///
475+
/// Called while the CRDT engine `&mut` borrow is still live (the borrow
476+
/// checker forbids touching `self.sparse` here), so it is an associated
477+
/// function over the borrowed engine rather than a method. Returns `None`
478+
/// when the row is absent or cannot be converted — the caller then skips
479+
/// the sparse write. A materialization miss must never fail the delta
480+
/// apply: the Loro merge has already succeeded and the sync stream must
481+
/// not wedge.
482+
fn encode_crdt_row(
483+
engine: &TenantCrdtEngine,
484+
collection: &str,
485+
document_id: &str,
486+
) -> Option<Vec<u8>> {
487+
let loro_val = engine.read_row(collection, document_id)?;
488+
let json = loro_value_to_json(&loro_val);
489+
let msgpack = match nodedb_types::json_to_msgpack(&json) {
490+
Ok(bytes) => bytes,
491+
Err(_) => return None,
492+
};
493+
Some(canonicalize_document_for_storage(&msgpack))
494+
}
495+
496+
/// Write the merged CRDT document into the sparse document store so
497+
/// `DocumentScan` / `ShapeSnapshot` observe the synced write, matching the
498+
/// key and bytes the native schemaless put path produces.
499+
///
500+
/// The storage key is the hex-encoded surrogate (identical to the native
501+
/// path), NOT the CRDT `document_id` (which is the user-facing Loro row
502+
/// id). Bitemporal collections append a version per applied delta;
503+
/// non-bitemporal collections overwrite by key (idempotent under replay).
504+
/// FTS is intentionally NOT indexed here — the sync path delivers a
505+
/// separate `FtsIndex` frame, and re-indexing would double-index. A write
506+
/// failure is logged and swallowed so a materialization miss never wedges
507+
/// the sync stream.
508+
fn materialize_synced_document(
509+
&self,
510+
database_id: u64,
511+
tid: u64,
512+
collection: &str,
513+
surrogate: Surrogate,
514+
stored: &[u8],
515+
) {
516+
let storage_key = surrogate_to_doc_id(surrogate);
517+
let result = if self.is_bitemporal(tid, collection) {
518+
self.sparse.versioned_put(VersionedPut {
519+
database_id,
520+
tenant: tid,
521+
coll: collection,
522+
doc_id: storage_key.as_str(),
523+
sys_from_ms: self.bitemporal_now_ms(),
524+
valid_from_ms: i64::MIN,
525+
valid_until_ms: i64::MAX,
526+
body: stored,
527+
})
528+
} else {
529+
self.sparse
530+
.put(database_id, tid, collection, storage_key.as_str(), stored)
531+
.map(|_| ())
532+
};
533+
if let Err(e) = result {
534+
warn!(
535+
core = self.core_id,
536+
%collection,
537+
document_id = %storage_key,
538+
error = %e,
539+
"crdt sync materialize into sparse document store failed"
540+
);
541+
}
542+
}
419543
}

0 commit comments

Comments
 (0)