@@ -198,7 +198,7 @@ impl CoreLoop {
198198 && cfg. index_type == crate :: engine:: vector:: index_config:: IndexType :: IvfPq
199199 {
200200 let key = index_key. clone ( ) ;
201- return self . ivf_insert ( task, & key, vector, dim, surrogate) ;
201+ return self . ivf_insert ( task, tid , & key, vector, dim, surrogate) ;
202202 }
203203
204204 // Default: HNSW (with or without PQ).
@@ -222,6 +222,15 @@ impl CoreLoop {
222222 warn ! ( core = self . core_id, error = %e, "failed to send HNSW build request" ) ;
223223 }
224224 self . checkpoint_coordinator . mark_dirty ( "vector" , 1 ) ;
225+ // Record this write's version so cross-shard OCC read-set
226+ // validation (predicate reads always record the collection
227+ // floor) sees this insert. `ZERO` means no surrogate binding
228+ // was made (headless insert) — floor-only.
229+ if surrogate == Surrogate :: ZERO {
230+ self . note_collection_write_lsn ( task, collection) ;
231+ } else {
232+ self . note_surrogate_write_lsn ( task, tid, collection, surrogate. as_u32 ( ) ) ;
233+ }
225234 self . response_ok ( task)
226235 }
227236 Err ( err) => self . response_error ( task, err) ,
@@ -232,6 +241,7 @@ impl CoreLoop {
232241 fn ivf_insert (
233242 & mut self ,
234243 task : & ExecutionTask ,
244+ tid : u64 ,
235245 index_key : & ( DatabaseId , TenantId , String ) ,
236246 vector : & [ f32 ] ,
237247 dim : usize ,
@@ -274,6 +284,14 @@ impl CoreLoop {
274284 }
275285
276286 self . checkpoint_coordinator . mark_dirty ( "vector" , 1 ) ;
287+ // Record this write's version so cross-shard OCC read-set validation
288+ // sees this insert, same as the HNSW insert path above. `ZERO` means
289+ // no surrogate binding was made (headless insert) — floor-only.
290+ if surrogate == Surrogate :: ZERO {
291+ self . note_collection_write_lsn ( task, & index_key. 2 ) ;
292+ } else {
293+ self . note_surrogate_write_lsn ( task, tid, & index_key. 2 , surrogate. as_u32 ( ) ) ;
294+ }
277295 self . response_ok ( task)
278296 }
279297
@@ -375,11 +393,147 @@ impl CoreLoop {
375393 . and_then ( |c| c. surrogate_to_local . get ( & surrogate) . copied ( ) ) ;
376394
377395 match node_id {
378- Some ( vid) => self . execute_vector_delete ( task, tid, collection, vid) ,
396+ Some ( vid) => {
397+ let response = self . execute_vector_delete ( task, tid, collection, vid) ;
398+ if response. status == crate :: bridge:: envelope:: Status :: Ok {
399+ // Record this write's version keyed by the cross-engine
400+ // surrogate (a superset of `execute_vector_delete`'s
401+ // node-id-scoped floor-only record below — correct and
402+ // more precise since the surrogate identity is known here).
403+ self . note_surrogate_write_lsn ( task, tid, collection, surrogate. as_u32 ( ) ) ;
404+ }
405+ response
406+ }
379407 None => {
380408 // Surrogate not present — idempotent.
381409 self . response_ok ( task)
382410 }
383411 }
384412 }
385413}
414+
415+ #[ cfg( test) ]
416+ mod tests {
417+ use super :: * ;
418+ use crate :: bridge:: envelope:: { PhysicalPlan , Priority , Request } ;
419+ use crate :: data:: executor:: core_loop:: write_index:: WriteKey ;
420+ use crate :: types:: { Lsn , RequestId , TraceId , VShardId } ;
421+ use nodedb_bridge:: buffer:: RingBuffer ;
422+ use nodedb_physical:: physical_plan:: VectorOp ;
423+ use std:: time:: { Duration , Instant } ;
424+
425+ struct CoreHarness {
426+ core : CoreLoop ,
427+ _req_tx : nodedb_bridge:: buffer:: Producer < crate :: bridge:: dispatch:: BridgeRequest > ,
428+ _resp_rx : nodedb_bridge:: buffer:: Consumer < crate :: bridge:: dispatch:: BridgeResponse > ,
429+ _dir : tempfile:: TempDir ,
430+ }
431+
432+ fn make_core ( ) -> CoreHarness {
433+ use crate :: bridge:: dispatch:: { BridgeRequest , BridgeResponse } ;
434+
435+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
436+ let ( req_tx, req_rx) = RingBuffer :: channel :: < BridgeRequest > ( 64 ) ;
437+ let ( resp_tx, resp_rx) = RingBuffer :: channel :: < BridgeResponse > ( 64 ) ;
438+ let core = CoreLoop :: open (
439+ 0 ,
440+ req_rx,
441+ resp_tx,
442+ dir. path ( ) ,
443+ std:: sync:: Arc :: new ( nodedb_types:: OrdinalClock :: new ( ) ) ,
444+ )
445+ . expect ( "open core" ) ;
446+ CoreHarness {
447+ core,
448+ _req_tx : req_tx,
449+ _resp_rx : resp_rx,
450+ _dir : dir,
451+ }
452+ }
453+
454+ /// A task carrying `wal_lsn` so the handler's `note_*_write_lsn` calls
455+ /// (gated on `task.wal_lsn().is_some()`) actually fire, mirroring a live
456+ /// write dispatched with an allocated WAL LSN.
457+ fn make_task_with_lsn ( lsn : u64 ) -> ExecutionTask {
458+ ExecutionTask :: new ( Request {
459+ request_id : RequestId :: new ( 1 ) ,
460+ tenant_id : TenantId :: new ( 1 ) ,
461+ database_id : DatabaseId :: DEFAULT ,
462+ vshard_id : VShardId :: new ( 0 ) ,
463+ plan : PhysicalPlan :: Vector ( VectorOp :: Search {
464+ collection : "docs" . to_string ( ) ,
465+ query_vector : Vec :: new ( ) ,
466+ top_k : 0 ,
467+ ef_search : 0 ,
468+ metric : nodedb_types:: vector_distance:: DistanceMetric :: L2 ,
469+ filter_bitmap : None ,
470+ field_name : String :: new ( ) ,
471+ rls_filters : Vec :: new ( ) ,
472+ inline_prefilter_plan : None ,
473+ ann_options : Default :: default ( ) ,
474+ skip_payload_fetch : false ,
475+ payload_filters : Vec :: new ( ) ,
476+ } ) ,
477+ deadline : Instant :: now ( ) + Duration :: from_secs ( 5 ) ,
478+ priority : Priority :: Normal ,
479+ trace_id : TraceId :: ZERO ,
480+ consistency : crate :: types:: ReadConsistency :: Strong ,
481+ idempotency_key : None ,
482+ event_source : crate :: event:: EventSource :: User ,
483+ user_roles : Vec :: new ( ) ,
484+ user_id : None ,
485+ statement_digest : None ,
486+ txn_id : None ,
487+ wal_lsn : Some ( Lsn :: new ( lsn) ) ,
488+ resolved_now_ms : None ,
489+ admission : crate :: bridge:: envelope:: Admission :: Exempt (
490+ crate :: bridge:: envelope:: ExemptReason :: Read ,
491+ ) ,
492+ } )
493+ }
494+
495+ #[ test]
496+ fn vector_insert_populates_write_version_index_surrogate_and_floor ( ) {
497+ let mut h = make_core ( ) ;
498+ let task = make_task_with_lsn ( 11 ) ;
499+ let surrogate = Surrogate :: new ( 42 ) ;
500+
501+ let response = h. core . execute_vector_insert ( VectorInsertParams {
502+ task : & task,
503+ tid : 1 ,
504+ collection : "docs" ,
505+ vector : & [ 1.0 , 2.0 , 3.0 ] ,
506+ dim : 3 ,
507+ field_name : "" ,
508+ surrogate,
509+ provenance : None ,
510+ } ) ;
511+ assert_eq ! ( response. status, crate :: bridge:: envelope:: Status :: Ok ) ;
512+
513+ let key = WriteKey {
514+ db : DatabaseId :: DEFAULT ,
515+ tenant : TenantId :: new ( 1 ) ,
516+ collection : Box :: from ( "docs" ) ,
517+ key : crate :: data:: executor:: core_loop:: write_index:: KeyRepr :: Surrogate (
518+ surrogate. as_u32 ( ) ,
519+ ) ,
520+ } ;
521+ assert_eq ! (
522+ h. core. write_index. key_write_lsn( & key) ,
523+ Some ( Lsn :: new( 11 ) ) ,
524+ "vector insert must populate the per-key (surrogate) write-version index"
525+ ) ;
526+
527+ let coll_key = crate :: data:: executor:: core_loop:: write_index:: CollKey {
528+ db : DatabaseId :: DEFAULT ,
529+ tenant : TenantId :: new ( 1 ) ,
530+ collection : Box :: from ( "docs" ) ,
531+ } ;
532+ assert_eq ! (
533+ h. core. write_index. collection_write_lsn( & coll_key) ,
534+ Some ( Lsn :: new( 11 ) ) ,
535+ "vector insert must advance the collection write-version floor \
536+ (predicate reads validate against the floor)"
537+ ) ;
538+ }
539+ }
0 commit comments