@@ -11,10 +11,14 @@ use nodedb_types::sync::wire::{AckStatus, SyncProvenance};
1111
1212use crate :: bridge:: envelope:: { ErrorCode , Response } ;
1313use crate :: data:: executor:: sync_gate:: SyncAdmit ;
14- use crate :: engine:: crdt:: tenant_state:: ValidatedApplyOutcome ;
14+ use crate :: engine:: crdt:: tenant_state:: { TenantCrdtEngine , ValidatedApplyOutcome } ;
1515
1616use crate :: data:: executor:: core_loop:: CoreLoop ;
17+ use crate :: data:: executor:: doc_format:: canonicalize_document_for_storage;
1718use 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
1923impl 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