Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

1use super::*;
2
3impl UnifiedStore {
4    pub(crate) fn persist_entities_to_pager(
5        &self,
6        collection: &str,
7        entities: &[UnifiedEntity],
8    ) -> Result<(), StoreError> {
9        self.persist_entities_impl(collection, entities, /* skip_btree= */ false)
10    }
11
12    /// Same as `persist_entities_to_pager` but takes `&[&UnifiedEntity]` so
13    /// callers that already hold references (SQL UPDATE fast path) don't
14    /// have to clone a `Vec<UnifiedEntity>` just to satisfy the signature.
15    pub(crate) fn persist_entity_refs_to_pager(
16        &self,
17        collection: &str,
18        entities: &[&UnifiedEntity],
19    ) -> Result<(), StoreError> {
20        self.persist_entity_refs_impl(collection, entities, /* skip_btree= */ false)
21    }
22
23    /// Reference-taking WAL-only variant — see
24    /// `persist_entities_to_pager_wal_only` for semantics.
25    pub(crate) fn persist_entity_refs_to_pager_wal_only(
26        &self,
27        collection: &str,
28        entities: &[&UnifiedEntity],
29    ) -> Result<(), StoreError> {
30        self.persist_entity_refs_impl(collection, entities, /* skip_btree= */ true)
31    }
32
33    fn persist_entity_refs_impl(
34        &self,
35        collection: &str,
36        entities: &[&UnifiedEntity],
37        skip_btree: bool,
38    ) -> Result<(), StoreError> {
39        if entities.is_empty() {
40            return Ok(());
41        }
42        if self.pager.is_none() && self.config.embedded_wal_path.is_none() {
43            return Ok(());
44        }
45        let fv = self.format_version();
46        let manager = self
47            .get_collection(collection)
48            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
49        let mut serialized: Vec<(Vec<u8>, Vec<u8>)> = entities
50            .iter()
51            .map(|entity| {
52                let metadata = manager.get_metadata(entity.id);
53                (
54                    entity.id.raw().to_be_bytes().to_vec(),
55                    Self::serialize_entity_record(entity, metadata.as_ref(), fv),
56                )
57            })
58            .collect();
59        serialized.sort_by(|a, b| a.0.cmp(&b.0));
60
61        if !skip_btree {
62            let Some(pager) = &self.pager else {
63                let records: Vec<Vec<u8>> =
64                    serialized.into_iter().map(|(_id, record)| record).collect();
65                self.finish_paged_write([StoreWalAction::BulkUpsertEntityRecords {
66                    collection: collection.to_string(),
67                    records,
68                }])?;
69                return Ok(());
70            };
71            let mut btree_indices = self.btree_indices.write();
72            let btree = btree_indices
73                .entry(collection.to_string())
74                .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
75            let root_before = btree.root_page_id();
76            btree.upsert_batch_sorted(&serialized).map_err(|e| {
77                StoreError::Io(std::io::Error::other(format!("B-tree upsert error: {}", e)))
78            })?;
79            let root_after = btree.root_page_id();
80            drop(btree_indices);
81            if root_before != root_after {
82                self.mark_paged_registry_dirty();
83            }
84        }
85        let records: Vec<Vec<u8>> = serialized.into_iter().map(|(_id, record)| record).collect();
86        self.finish_paged_write([StoreWalAction::BulkUpsertEntityRecords {
87            collection: collection.to_string(),
88            records,
89        }])?;
90        Ok(())
91    }
92
93    /// PG-HOT-like UPDATE persist: emit the WAL record so the update
94    /// survives a crash, but skip the in-line B-tree upsert. Reads
95    /// continue to see the new entity because `manager.get()` prefers
96    /// the segment over the B-tree; WAL replay re-applies the upsert
97    /// to the B-tree on recovery so the two views converge before
98    /// any stale B-tree page can be surfaced.
99    ///
100    /// Safe to use only when the caller has already updated the
101    /// segment in-place (so live reads see the new value). Cuts out
102    /// the dominant cost of single-row UPDATEs: per-entity serialize
103    /// + leaf descent + page decompress / compress / write_page.
104    pub(crate) fn persist_entities_to_pager_wal_only(
105        &self,
106        collection: &str,
107        entities: &[UnifiedEntity],
108    ) -> Result<(), StoreError> {
109        self.persist_entities_impl(collection, entities, /* skip_btree= */ true)
110    }
111
112    fn persist_entities_impl(
113        &self,
114        collection: &str,
115        entities: &[UnifiedEntity],
116        skip_btree: bool,
117    ) -> Result<(), StoreError> {
118        if entities.is_empty() {
119            return Ok(());
120        }
121
122        if self.pager.is_none() && self.config.embedded_wal_path.is_none() {
123            return Ok(());
124        }
125
126        let fv = self.format_version();
127        let manager = self
128            .get_collection(collection)
129            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
130        let mut serialized: Vec<(Vec<u8>, Vec<u8>)> = entities
131            .iter()
132            .map(|entity| {
133                let metadata = manager.get_metadata(entity.id);
134                (
135                    entity.id.raw().to_be_bytes().to_vec(),
136                    Self::serialize_entity_record(entity, metadata.as_ref(), fv),
137                )
138            })
139            .collect();
140        // u64 IDs encoded as big-endian — lex order = numeric order.
141        serialized.sort_by(|a, b| a.0.cmp(&b.0));
142
143        if !skip_btree {
144            let Some(pager) = &self.pager else {
145                let records: Vec<Vec<u8>> =
146                    serialized.into_iter().map(|(_id, record)| record).collect();
147                self.finish_paged_write([StoreWalAction::BulkUpsertEntityRecords {
148                    collection: collection.to_string(),
149                    records,
150                }])?;
151                return Ok(());
152            };
153            let mut btree_indices = self.btree_indices.write();
154            let btree = btree_indices
155                .entry(collection.to_string())
156                .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
157            let root_before = btree.root_page_id();
158
159            // Walks each distinct leaf once, applies all in-place overwrites
160            // that belong there under one read+write. Keys that miss or grow
161            // fall back to the per-key `upsert` path internally.
162            btree.upsert_batch_sorted(&serialized).map_err(|e| {
163                StoreError::Io(std::io::Error::other(format!("B-tree upsert error: {}", e)))
164            })?;
165            let root_after = btree.root_page_id();
166            drop(btree_indices);
167            if root_before != root_after {
168                self.mark_paged_registry_dirty();
169            }
170        }
171        // One WAL action covering the whole bulk. Previously each entity
172        // produced its own `UpsertEntityRecord` action, which then became
173        // its own `WalRecord::PageWrite` triple inside
174        // `StoreCommitCoordinator::append_actions` — N WAL records per
175        // bulk. The batched variant is one WAL record per bulk.
176        //
177        // WAL is emitted even in the `skip_btree` variant — recovery
178        // replay reapplies the upsert to the B-tree, so durability
179        // is unchanged.
180        let records: Vec<Vec<u8>> = serialized.into_iter().map(|(_id, record)| record).collect();
181        self.finish_paged_write([StoreWalAction::BulkUpsertEntityRecords {
182            collection: collection.to_string(),
183            records,
184        }])?;
185
186        Ok(())
187    }
188
189    /// Insert a label→entity_id mapping into the graph label index.
190    pub(crate) fn update_graph_label_index(
191        &self,
192        collection: &str,
193        label: &str,
194        entity_id: EntityId,
195    ) {
196        let key = (collection.to_string(), label.to_string());
197        let mut idx = self.graph_label_index.write();
198        idx.entry(key).or_default().push(entity_id);
199    }
200
201    /// Remove a specific entity_id from the graph label index (called on delete).
202    pub(crate) fn remove_from_graph_label_index(&self, collection: &str, entity_id: EntityId) {
203        let mut idx = self.graph_label_index.write();
204        for ((col, _), ids) in idx.iter_mut() {
205            if col == collection {
206                ids.retain(|&id| id != entity_id);
207            }
208        }
209        // Prune empty entries to keep the index compact
210        idx.retain(|_, ids| !ids.is_empty());
211    }
212
213    pub(crate) fn remove_from_graph_label_index_batch(
214        &self,
215        collection: &str,
216        entity_ids: &[EntityId],
217    ) {
218        if entity_ids.is_empty() {
219            return;
220        }
221        let id_set: std::collections::HashSet<EntityId> = entity_ids.iter().copied().collect();
222        let mut idx = self.graph_label_index.write();
223        for ((col, _), ids) in idx.iter_mut() {
224            if col == collection {
225                ids.retain(|id| !id_set.contains(id));
226            }
227        }
228        idx.retain(|_, ids| !ids.is_empty());
229    }
230
231    /// Look up entity IDs for a graph node label across all collections.
232    pub fn lookup_graph_nodes_by_label(&self, label: &str) -> Vec<EntityId> {
233        let idx = self.graph_label_index.read();
234        idx.iter()
235            .filter(|((_, l), _)| l == label)
236            .flat_map(|(_, ids)| ids.iter().copied())
237            .collect()
238    }
239
240    /// Look up entity IDs for a graph node label scoped to a single collection.
241    pub fn lookup_graph_nodes_by_label_in(&self, collection: &str, label: &str) -> Vec<EntityId> {
242        let idx = self.graph_label_index.read();
243        idx.get(&(collection.to_string(), label.to_string()))
244            .cloned()
245            .unwrap_or_default()
246    }
247
248    pub fn create_collection(&self, name: impl Into<String>) -> Result<(), StoreError> {
249        let name = name.into();
250        let mut collections = self.collections.write();
251
252        if collections.contains_key(&name) {
253            return Err(StoreError::CollectionExists(name));
254        }
255
256        let manager = SegmentManager::with_config(&name, self.config.manager_config.clone());
257        collections.insert(name.clone(), Arc::new(manager));
258        drop(collections);
259
260        let create = match self.prepare_operational_collection_create(&name) {
261            Ok(create) => create,
262            Err(err) => {
263                let mut collections = self.collections.write();
264                collections.remove(&name);
265                return Err(err);
266            }
267        };
268        if let Some(create) = create {
269            if let Err(err) = create.publish().map_err(StoreError::Io) {
270                let mut collections = self.collections.write();
271                collections.remove(&name);
272                return Err(err);
273            }
274        }
275
276        self.mark_paged_registry_dirty();
277        self.finish_paged_write([StoreWalAction::CreateCollection { name }])?;
278
279        Ok(())
280    }
281
282    /// Get or create a collection
283    pub fn get_or_create_collection(&self, name: impl Into<String>) -> Arc<SegmentManager> {
284        let name = name.into();
285        // Fast path: shared read lock — zero contention for existing collections
286        {
287            let collections = self.collections.read();
288            if let Some(manager) = collections.get(&name) {
289                return Arc::clone(manager);
290            }
291        }
292        // Slow path: exclusive write lock — only when collection is missing
293        let mut collections = self.collections.write();
294        // Double-check after acquiring write lock (another thread may have created it)
295        if let Some(manager) = collections.get(&name) {
296            return Arc::clone(manager);
297        }
298        let manager = Arc::new(SegmentManager::with_config(
299            &name,
300            self.config.manager_config.clone(),
301        ));
302        collections.insert(name, Arc::clone(&manager));
303        self.mark_paged_registry_dirty();
304        manager
305    }
306
307    /// Get a collection
308    pub fn get_collection(&self, name: &str) -> Option<Arc<SegmentManager>> {
309        self.collections.read().get(name).map(Arc::clone)
310    }
311
312    /// Get the context index for cross-structure search.
313    pub fn context_index(&self) -> &ContextIndex {
314        &self.context_index
315    }
316
317    /// Drain WAL-replayed `VectorInsert` records for `collection`
318    /// (issue #694). Returns `None` when nothing was captured at open
319    /// time (in-memory mode, fresh database, or non-turbo collection).
320    /// One-shot: the caller owns the records after this call, the
321    /// store's internal buffer is cleared for that collection.
322    pub fn take_replayed_turbo_inserts(&self, collection: &str) -> Option<Vec<(u64, Vec<f32>)>> {
323        let mut map = self.replayed_turbo_inserts.lock();
324        map.remove(collection)
325    }
326
327    pub(crate) fn take_replayed_probabilistic_deltas(&self) -> Vec<(u8, u8, String, Vec<Vec<u8>>)> {
328        let mut deltas = self.replayed_probabilistic_deltas.lock();
329        std::mem::take(&mut *deltas)
330    }
331
332    /// Set multiple config KV pairs at once from a JSON tree.
333    /// Keys are flattened with dot-notation: `{"a":{"b":1}}` → `a.b = 1`.
334    pub fn set_config_tree(&self, prefix: &str, json: &crate::serde_json::Value) -> usize {
335        let _ = self.get_or_create_collection("red_config");
336        let mut pairs = Vec::new();
337        flatten_config_json(prefix, json, &mut pairs);
338        let mut saved = 0;
339        for (key, value) in pairs {
340            let entity = UnifiedEntity::new(
341                EntityId::new(0),
342                EntityKind::TableRow {
343                    table: Arc::from("red_config"),
344                    row_id: 0,
345                },
346                EntityData::Row(RowData {
347                    columns: Vec::new(),
348                    named: Some(
349                        [
350                            ("key".to_string(), reddb_types::Value::text(key)),
351                            ("value".to_string(), value),
352                        ]
353                        .into_iter()
354                        .collect(),
355                    ),
356                    schema: None,
357                }),
358            );
359            if self.insert_auto("red_config", entity).is_ok() {
360                saved += 1;
361            }
362        }
363        saved
364    }
365
366    /// Read a single config value from `red_config` by dot-notation key.
367    pub fn get_config(&self, key: &str) -> Option<reddb_types::Value> {
368        let manager = self.get_collection("red_config")?;
369        for entity in manager.query_all(|_| true) {
370            if let EntityData::Row(row) = &entity.data {
371                if let Some(named) = &row.named {
372                    let key_matches = named
373                        .get("key")
374                        .and_then(|v| match v {
375                            reddb_types::Value::Text(s) => Some(s.as_ref() == key),
376                            _ => None,
377                        })
378                        .unwrap_or(false);
379                    if key_matches {
380                        return named.get("value").cloned();
381                    }
382                }
383            }
384        }
385        None
386    }
387
388    /// Replace the opaque store-level auxiliary metadata blob. Persisted
389    /// inside the binary dump (store format V10+); see the `aux_metadata`
390    /// field. RedDB uses it to carry collection contracts through the
391    /// single-file artifact.
392    pub fn set_aux_metadata(&self, bytes: Vec<u8>) {
393        *self.aux_metadata.write() = bytes;
394    }
395
396    /// Read the opaque store-level auxiliary metadata blob. Empty when unset.
397    pub fn aux_metadata(&self) -> Vec<u8> {
398        self.aux_metadata.read().clone()
399    }
400
401    /// List all collections
402    pub fn list_collections(&self) -> Vec<String> {
403        self.collections.read().keys().cloned().collect()
404    }
405
406    /// Drop a collection
407    pub fn drop_collection(&self, name: &str) -> Result<(), StoreError> {
408        let drop = self.prepare_operational_collection_drop(name)?;
409        let manager = {
410            let mut collections = self.collections.write();
411
412            collections
413                .remove(name)
414                .ok_or_else(|| StoreError::CollectionNotFound(name.to_string()))?
415        };
416
417        let entities = manager.query_all(|_| true);
418        let entity_ids: Vec<EntityId> = entities.iter().map(|entity| entity.id).collect();
419
420        for entity_id in &entity_ids {
421            self.context_index.remove_entity(*entity_id);
422            let _ = self.unindex_cross_refs(*entity_id);
423        }
424
425        self.btree_indices.write().remove(name);
426
427        self.entity_cache.retain(|entity_id, (collection, _)| {
428            collection != name && !entity_ids.iter().any(|id| id.raw() == entity_id)
429        });
430
431        self.cross_refs.write().retain(|source_id, refs| {
432            refs.retain(|(target_id, _, target_collection)| {
433                target_collection != name && !entity_ids.iter().any(|id| id == target_id)
434            });
435            !entity_ids.iter().any(|id| id == source_id)
436        });
437
438        self.reverse_refs.write().retain(|target_id, refs| {
439            refs.retain(|(source_id, _, source_collection)| {
440                source_collection != name && !entity_ids.iter().any(|id| id == source_id)
441            });
442            !entity_ids.iter().any(|id| id == target_id)
443        });
444
445        self.mark_paged_registry_dirty();
446        self.finish_paged_write([StoreWalAction::DropCollection {
447            name: name.to_string(),
448        }])?;
449        if let Some(drop) = drop {
450            drop.finish().map_err(StoreError::Io)?;
451        }
452
453        Ok(())
454    }
455
456    /// Insert an entity into a collection
457    pub fn insert(&self, collection: &str, entity: UnifiedEntity) -> Result<EntityId, StoreError> {
458        let manager = self
459            .get_collection(collection)
460            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
461
462        let mut entity = entity;
463        if entity.id.raw() == 0 {
464            entity.id = self.next_entity_id();
465        } else {
466            self.register_entity_id(entity.id);
467        }
468        // Assign per-table sequential row_id if not set
469        if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
470            if *row_id == 0 {
471                *row_id = manager.next_row_id();
472            } else {
473                manager.register_row_id(*row_id);
474            }
475        }
476        entity.ensure_table_logical_id();
477        // Capture graph node label before entity is moved into the manager
478        let graph_node_label: Option<String> = if let EntityKind::GraphNode(ref node) = entity.kind
479        {
480            Some(node.label.clone())
481        } else {
482            None
483        };
484
485        let id = manager.insert(entity)?;
486        self.register_entity_id(id);
487
488        // Update graph label index for GraphNode entities
489        if let Some(ref label) = graph_node_label {
490            self.update_graph_label_index(collection, label, id);
491        }
492
493        // Also insert into B-tree index if pager is active
494        let mut registry_dirty = false;
495        if let Some(pager) = &self.pager {
496            if let Some(entity) = manager.get(id) {
497                let mut btree_indices = self.btree_indices.write();
498                let btree = btree_indices
499                    .entry(collection.to_string())
500                    .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
501                let root_before = btree.root_page_id();
502
503                let key = id.raw().to_be_bytes();
504                let metadata = manager.get_metadata(id);
505                let value = Self::serialize_entity_record(
506                    &entity,
507                    metadata.as_ref(),
508                    self.format_version(),
509                );
510                // Ignore duplicate key errors (update scenario)
511                let _ = btree.insert(&key, &value);
512                registry_dirty = root_before != btree.root_page_id();
513            }
514        }
515
516        // Index cross-references if enabled
517        if self.config.auto_index_refs {
518            if let Some(entity) = manager.get(id) {
519                self.index_cross_refs(&entity, collection)?;
520            }
521        }
522
523        // Perf: skip WAL-action construction when the store is
524        // pagerless. For in-memory benchmarks this saved another
525        // `manager.get(id)` + `serialize_entity_record` per call.
526        if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
527            let actions = manager
528                .get(id)
529                .map(|entity| {
530                    let metadata = manager.get_metadata(id);
531                    vec![StoreWalAction::upsert_entity(
532                        collection,
533                        &entity,
534                        metadata.as_ref(),
535                        self.format_version(),
536                    )]
537                })
538                .unwrap_or_default();
539            if registry_dirty {
540                self.mark_paged_registry_dirty();
541            }
542            self.finish_paged_write(actions)?;
543        }
544
545        Ok(id)
546    }
547
548    /// Turbo bulk insert — optimized fast path.
549    ///
550    /// Single lock for the entire batch. Skips bloom filter,
551    /// context index, and cross-ref indexing. B-tree writes are batched.
552    pub fn bulk_insert(
553        &self,
554        collection: &str,
555        mut entities: Vec<UnifiedEntity>,
556    ) -> Result<Vec<EntityId>, StoreError> {
557        // REDDB_BULK_TIMING=1 prints a per-call breakdown of the bulk
558        // insert path to stderr. Off by default — used by the reddb
559        // benchmark harness to locate ingest bottlenecks.
560        let trace = matches!(
561            std::env::var("REDDB_BULK_TIMING").ok().as_deref(),
562            Some("1") | Some("true") | Some("on")
563        );
564        let t_start = std::time::Instant::now();
565        let n = entities.len();
566        let manager = self.get_or_create_collection(collection);
567        let t_get_coll = t_start.elapsed();
568
569        // Assign IDs and per-table row_ids before serialization. Bulk
570        // insert must follow the same global ID semantics as
571        // insert()/insert_auto(). Reserve ID ranges in single fetch_add
572        // calls instead of one-per-entity. The overwhelming common case
573        // is "every entity comes in with id==0 and kind==TableRow with
574        // row_id==0" (the wire bulk insert path). Any entity that
575        // already carries an id or row_id falls back to the individual
576        // register_* path to preserve those semantics exactly.
577        let t0 = std::time::Instant::now();
578        let n_missing_entity_ids = entities.iter().filter(|e| e.id.raw() == 0).count() as u64;
579        let n_missing_row_ids = entities
580            .iter()
581            .filter(|e| matches!(e.kind, EntityKind::TableRow { row_id: 0, .. }))
582            .count() as u64;
583        let mut entity_id_range = if n_missing_entity_ids > 0 {
584            self.reserve_entity_ids(n_missing_entity_ids)
585        } else {
586            0..0
587        };
588        let mut row_id_range = if n_missing_row_ids > 0 {
589            manager.reserve_row_ids(n_missing_row_ids)
590        } else {
591            0..0
592        };
593        for entity in &mut entities {
594            if entity.id.raw() == 0 {
595                let next = entity_id_range
596                    .next()
597                    .expect("reserved entity-id range exhausted");
598                entity.id = EntityId::new(next);
599            } else {
600                self.register_entity_id(entity.id);
601            }
602            if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
603                if *row_id == 0 {
604                    *row_id = row_id_range
605                        .next()
606                        .expect("reserved row-id range exhausted");
607                } else {
608                    manager.register_row_id(*row_id);
609                }
610            }
611            entity.ensure_table_logical_id();
612        }
613        let t_assign_ids = t0.elapsed();
614
615        // Capture graph node labels before entities are moved into the segment manager
616        let graph_labels: Vec<Option<(String, EntityId)>> = entities
617            .iter()
618            .map(|e| {
619                if let EntityKind::GraphNode(ref node) = e.kind {
620                    Some((node.label.clone(), e.id))
621                } else {
622                    None
623                }
624            })
625            .collect();
626
627        // Pre-serialize for B-tree while we still have references.
628        // `serialize_entity_record` is pure; with `n >= 1024` the
629        // rayon dispatch cost is amortised and the 16-core serialize
630        // fan-out becomes the biggest single win on insert_bulk. For
631        // smaller batches, stay serial — micro-batches pay more in
632        // work-stealing overhead than they save.
633        let t0 = std::time::Instant::now();
634        let serialized: Option<Vec<(Vec<u8>, Vec<u8>)>> =
635            if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
636                let fv = self.format_version();
637                let serial_map = |e: &UnifiedEntity| {
638                    (
639                        e.id.raw().to_be_bytes().to_vec(),
640                        Self::serialize_entity_record(e, None, fv),
641                    )
642                };
643                // Gate chosen to match the bench's `BULK_BATCH_SIZE=1000`.
644                // Rayon's dispatch overhead is ~30µs/batch — on a 15-col
645                // row serializing for ~40µs the break-even is around
646                // 200-300 rows on a 16-core host. Keep a safety margin
647                // at 512.
648                if entities.len() >= 512 {
649                    use rayon::prelude::*;
650                    Some(entities.par_iter().map(serial_map).collect())
651                } else {
652                    Some(entities.iter().map(serial_map).collect())
653                }
654            } else {
655                None
656            };
657        let t_serialize = t0.elapsed();
658
659        // Move entities into segment
660        let t0 = std::time::Instant::now();
661        let ids = manager.bulk_insert(entities)?;
662        let t_manager = t0.elapsed();
663        for id in &ids {
664            self.register_entity_id(*id);
665        }
666
667        // Update graph label index for bulk-inserted GraphNode entities
668        for (label, entity_id) in graph_labels.iter().flatten() {
669            self.update_graph_label_index(collection, label, *entity_id);
670        }
671
672        // REDDB_BULK_SKIP_PERSIST_UNSAFE=1 skips the persistent B-tree index
673        // during bulk ingest.
674        //
675        // UNSAFE: for ephemeral benchmark containers ONLY.
676        // This flag is silently ignored when a pager (durable storage) is active.
677        // In persistent mode, bulk inserts ALWAYS write to the B-tree so the data
678        // survives a cold restart without any manual rebuild step.
679        //
680        // The flag is only honoured when self.pager is None (in-memory / ephemeral).
681        let skip_btree_requested = matches!(
682            std::env::var("REDDB_BULK_SKIP_PERSIST_UNSAFE")
683                .ok()
684                .as_deref(),
685            Some("1") | Some("true") | Some("on")
686        );
687        // Honour the flag only when there is no durable pager.
688        // If a pager exists we are in persistent mode → always persist.
689        let skip_btree = skip_btree_requested && self.pager.is_none();
690        if skip_btree_requested && !skip_btree {
691            // Flag was set but we ignored it because we have a real pager.
692            static IGNORED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
693            IGNORED.get_or_init(|| {
694                tracing::warn!(
695                    "REDDB_BULK_SKIP_PERSIST_UNSAFE set but durable pager is \
696                     active — flag ignored; bulk inserts will be persisted normally"
697                );
698            });
699        } else if skip_btree {
700            // Ephemeral mode and flag is active — warn once.
701            static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
702            WARNED.get_or_init(|| {
703                tracing::warn!(
704                    "REDDB_BULK_SKIP_PERSIST_UNSAFE set (ephemeral/no-pager mode) — \
705                     bulk inserts NOT durable; data will be lost on restart"
706                );
707            });
708        }
709
710        // Batch B-tree write from pre-serialized data.
711        // Uses sorted bulk insert: walks to a leaf once, appends many entries,
712        // writes each leaf exactly once per batch — O(N) instead of O(N²).
713        let mut t_btree_lock = std::time::Duration::ZERO;
714        let mut t_btree_insert = std::time::Duration::ZERO;
715        let mut t_flush = std::time::Duration::ZERO;
716        if !skip_btree {
717            if let (Some(pager), Some(batch)) = (&self.pager, serialized.as_ref()) {
718                let t0 = std::time::Instant::now();
719                let mut btree_indices = self.btree_indices.write();
720                let btree = btree_indices
721                    .entry(collection.to_string())
722                    .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
723                let root_before = btree.root_page_id();
724                t_btree_lock = t0.elapsed();
725
726                let t0 = std::time::Instant::now();
727                let _ = btree.bulk_insert_sorted(batch);
728                t_btree_insert = t0.elapsed();
729                let registry_dirty = root_before != btree.root_page_id();
730
731                let t0 = std::time::Instant::now();
732                if registry_dirty {
733                    self.mark_paged_registry_dirty();
734                }
735                t_flush = t0.elapsed();
736            }
737        }
738
739        // Fold 25k per-entity `UpsertEntityRecord` actions into one
740        // `BulkUpsertEntityRecords` — same on-disk semantics (replay
741        // applies each record by id), one `WalRecord::PageWrite`
742        // instead of N, and `record.clone()` drops out because we
743        // consume `serialized`.
744        let actions = serialized
745            .map(|batch| {
746                let records: Vec<Vec<u8>> =
747                    batch.into_iter().map(|(_key, record)| record).collect();
748                vec![StoreWalAction::BulkUpsertEntityRecords {
749                    collection: collection.to_string(),
750                    records,
751                }]
752            })
753            .unwrap_or_default();
754        self.finish_paged_write(actions)?;
755
756        if trace {
757            tracing::debug!(
758                n,
759                total = ?t_start.elapsed(),
760                get_coll = ?t_get_coll,
761                assign = ?t_assign_ids,
762                serialize = ?t_serialize,
763                manager = ?t_manager,
764                btree_lock = ?t_btree_lock,
765                btree = ?t_btree_insert,
766                flush = ?t_flush,
767                "bulk_insert timing"
768            );
769        }
770
771        Ok(ids)
772    }
773
774    /// Insert an entity, creating collection if needed
775    pub fn insert_auto(
776        &self,
777        collection: &str,
778        entity: UnifiedEntity,
779    ) -> Result<EntityId, StoreError> {
780        let manager = self.get_or_create_collection(collection);
781        let mut entity = entity;
782        if entity.id.raw() == 0 {
783            entity.id = self.next_entity_id();
784        } else {
785            self.register_entity_id(entity.id);
786        }
787        // Assign per-table sequential row_id if not set
788        if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
789            if *row_id == 0 {
790                *row_id = manager.next_row_id();
791            } else {
792                manager.register_row_id(*row_id);
793            }
794        }
795        entity.ensure_table_logical_id();
796
797        // Capture graph node label before entity is moved into the manager
798        let graph_node_label: Option<String> = if let EntityKind::GraphNode(ref node) = entity.kind
799        {
800            Some(node.label.clone())
801        } else {
802            None
803        };
804        // Index into context index before consuming the entity
805        self.context_index.index_entity(collection, &entity);
806
807        // Pre-serialize the entity record (B-tree image == WAL action body)
808        // and index cross-refs while we still own the entity. Mirrors the
809        // bulk_insert path (see line 559-580 above): segment.insert assigns
810        // a fresh `sequence_id`, but the bulk path already accepts a
811        // sequence_id=0 on-disk image (replay calls manager.insert which
812        // re-assigns sequence_id from the segment's allocator), so the
813        // single-row path can do the same. Fresh inserts never carry
814        // metadata — set_metadata is a separate call — so we pass None,
815        // matching how the bulk path serializes records.
816        //
817        // Eliminates the post-insert `manager.get(id)` clone of the just-
818        // inserted UnifiedEntity (HashMap<String, Value> + per-field
819        // Strings). Per `docs/perf/insert_sequential-2026-05-05.md` P2 this
820        // was the dominant clone in the insert_sequential hot path.
821        let id_for_serialize = entity.id;
822        let serialized_record: Option<Vec<u8>> =
823            if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
824                Some(Self::serialize_entity_record(
825                    &entity,
826                    None,
827                    self.format_version(),
828                ))
829            } else {
830                None
831            };
832        if self.config.auto_index_refs {
833            self.index_cross_refs(&entity, collection)?;
834        }
835
836        let id = manager.insert(entity)?;
837        debug_assert_eq!(id, id_for_serialize);
838        // `register_entity_id` already advances the atomic counter on
839        // the allocation path above (`self.next_entity_id()`), so the
840        // second call here is a no-op CAS loop on the hot path. Only
841        // needed for the caller-supplied-id branch which happens via
842        // the `register_entity_id` call on line 573.
843
844        // Update graph label index for GraphNode entities
845        if let Some(ref label) = graph_node_label {
846            self.update_graph_label_index(collection, label, id);
847        }
848
849        let mut registry_dirty = false;
850        if let (Some(_pager), Some(record)) = (&self.pager, serialized_record.as_ref()) {
851            if let Some(btree) = self.get_or_create_btree(collection) {
852                let root_before = btree.root_page_id();
853
854                let key = id.raw().to_be_bytes();
855                btree.insert(&key, record).map_err(|e| {
856                    StoreError::Io(std::io::Error::other(format!(
857                        "B-tree insert error while inserting '{collection}'/{id}: {e}"
858                    )))
859                })?;
860                registry_dirty = root_before != btree.root_page_id();
861            }
862        }
863
864        // Perf: pagerless → skip WAL-action construction (saves a
865        // third manager.get + entity serialize per insert). For
866        // in-memory runtimes finish_paged_write is a no-op.
867        if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
868            let actions = serialized_record
869                .map(|record| {
870                    vec![StoreWalAction::UpsertEntityRecord {
871                        collection: collection.to_string(),
872                        record,
873                    }]
874                })
875                .unwrap_or_default();
876            if registry_dirty {
877                self.mark_paged_registry_dirty();
878            }
879            self.finish_paged_write(actions)?;
880        }
881        Ok(id)
882    }
883
884    /// Get an entity from a collection
885    ///
886    /// Prefers the live SegmentManager view so reads after update/delete observe
887    /// the current in-memory state even when the paged B-tree image has not been
888    /// refreshed yet. Falls back to the B-tree image for recovery-oriented reads.
889    pub fn get(&self, collection: &str, id: EntityId) -> Option<UnifiedEntity> {
890        // Prefer the live manager state to avoid stale reads after manager.update().
891        if let Some(entity) = self
892            .get_collection(collection)
893            .and_then(|manager| manager.get(id))
894        {
895            return Some(entity);
896        }
897
898        // Fall back to the paged B-tree image if the manager does not currently hold the row.
899        if self.pager.is_some() {
900            let btree_indices = self.btree_indices.read();
901            if let Some(btree) = btree_indices.get(collection) {
902                let key = id.raw().to_be_bytes();
903                if let Ok(Some(value)) = btree.get(&key) {
904                    if let Ok((entity, _)) =
905                        Self::deserialize_entity_record(&value, self.format_version())
906                    {
907                        return Some(entity);
908                    }
909                }
910            }
911        }
912
913        None
914    }
915
916    /// Resolve a table row by stable logical identity.
917    ///
918    /// Legacy rows use `logical_id == id`, so the physical-id lookup remains
919    /// the fast path. Versioned rows may have a different physical id and fall
920    /// back to a scan until the history-store/index slices add a dedicated map.
921    pub fn get_table_row_by_logical_id(
922        &self,
923        collection: &str,
924        logical_id: EntityId,
925    ) -> Option<UnifiedEntity> {
926        if let Some(entity) = self.get(collection, logical_id) {
927            if matches!(entity.kind, EntityKind::TableRow { .. })
928                && entity.logical_id() == logical_id
929                && entity.xmax == 0
930            {
931                return Some(entity);
932            }
933        }
934
935        let manager = self.get_collection(collection)?;
936        let mut matches = manager.query_all(|entity| {
937            matches!(entity.kind, EntityKind::TableRow { .. }) && entity.logical_id() == logical_id
938        });
939        matches
940            .iter()
941            .find(|entity| entity.xmax == 0)
942            .cloned()
943            .or_else(|| matches.pop())
944    }
945
946    pub fn table_row_versions_by_logical_id(
947        &self,
948        collection: &str,
949        logical_id: EntityId,
950    ) -> Vec<UnifiedEntity> {
951        self.get_collection(collection)
952            .map(|manager| {
953                manager.query_all(|entity| {
954                    matches!(entity.kind, EntityKind::TableRow { .. })
955                        && entity.logical_id() == logical_id
956                })
957            })
958            .unwrap_or_default()
959    }
960
961    pub fn vacuum_mvcc_history(
962        &self,
963        collection: &str,
964        cutoff_xid: u64,
965    ) -> Result<MvccVacuumStats, StoreError> {
966        let manager = self
967            .get_collection(collection)
968            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
969        let entities =
970            manager.query_all(|entity| matches!(entity.kind, EntityKind::TableRow { .. }));
971        let mut logical = HashMap::<EntityId, (bool, u64)>::new();
972        for entity in &entities {
973            let entry = logical.entry(entity.logical_id()).or_insert((false, 0));
974            if entity.xmax == 0 {
975                entry.0 = true;
976            }
977            entry.1 = entry.1.max(entity.xmin);
978        }
979
980        let mut stats = MvccVacuumStats::default();
981        let mut reclaim_ids = Vec::new();
982        for entity in entities {
983            if entity.xmax == 0 {
984                continue;
985            }
986            stats.scanned_versions += 1;
987            let (has_live_version, max_xmin) = logical
988                .get(&entity.logical_id())
989                .copied()
990                .unwrap_or((false, entity.xmin));
991            let is_delete_tombstone = !has_live_version && entity.xmin == max_xmin;
992            if entity.xmax < cutoff_xid {
993                stats.reclaimed_versions += 1;
994                if is_delete_tombstone {
995                    stats.reclaimed_tombstones += 1;
996                } else {
997                    stats.reclaimed_history_versions += 1;
998                }
999                reclaim_ids.push(entity.id);
1000            } else {
1001                stats.retained_versions += 1;
1002                if is_delete_tombstone {
1003                    stats.retained_tombstones += 1;
1004                } else {
1005                    stats.retained_history_versions += 1;
1006                }
1007            }
1008        }
1009
1010        if !reclaim_ids.is_empty() {
1011            self.delete_batch(collection, &reclaim_ids)?;
1012        }
1013        Ok(stats)
1014    }
1015
1016    pub(crate) fn install_versioned_table_row_update(
1017        &self,
1018        collection: &str,
1019        old_version: UnifiedEntity,
1020        mut new_version: UnifiedEntity,
1021        metadata: Option<&Metadata>,
1022    ) -> Result<(), StoreError> {
1023        let manager = self
1024            .get_collection(collection)
1025            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1026
1027        let old_id = old_version.id;
1028        let new_id = new_version.id;
1029        let inherited_metadata = metadata.cloned().or_else(|| manager.get_metadata(old_id));
1030
1031        self.entity_cache.remove(old_id.raw());
1032        self.entity_cache.remove(new_id.raw());
1033        manager.update(old_version.clone())?;
1034        self.context_index.remove_entity(old_id);
1035
1036        self.register_entity_id(new_version.id);
1037        if let EntityKind::TableRow { ref mut row_id, .. } = new_version.kind {
1038            if *row_id == 0 {
1039                *row_id = manager.next_row_id();
1040            } else {
1041                manager.register_row_id(*row_id);
1042            }
1043        }
1044        new_version.ensure_table_logical_id();
1045        manager.insert(new_version.clone())?;
1046        if let Some(metadata) = inherited_metadata {
1047            manager.set_metadata(new_id, metadata)?;
1048        }
1049        self.context_index.index_entity(collection, &new_version);
1050        if self.config.auto_index_refs {
1051            self.index_cross_refs(&new_version, collection)?;
1052        }
1053
1054        let old_metadata = manager.get_metadata(old_id);
1055        let new_metadata = manager.get_metadata(new_id);
1056        let fv = self.format_version();
1057        let records = vec![
1058            Self::serialize_entity_record(&old_version, old_metadata.as_ref(), fv),
1059            Self::serialize_entity_record(&new_version, new_metadata.as_ref(), fv),
1060        ];
1061        self.finish_paged_write([StoreWalAction::BulkUpsertEntityRecords {
1062            collection: collection.to_string(),
1063            records,
1064        }])?;
1065
1066        Ok(())
1067    }
1068
1069    /// Batch-fetch multiple entities from the same collection in minimal lock acquisitions.
1070    ///
1071    /// Preferred over N individual `get()` calls in indexed-scan loops (sorted index,
1072    /// bitmap, hash). Reduces lock acquisitions from N×3 to 2-3 total.
1073    /// Preserves input order: `result[i]` corresponds to `ids[i]`.
1074    pub fn get_batch(&self, collection: &str, ids: &[EntityId]) -> Vec<Option<UnifiedEntity>> {
1075        match self.get_collection(collection) {
1076            Some(manager) => manager.get_many(ids),
1077            None => vec![None; ids.len()],
1078        }
1079    }
1080
1081    /// Get an entity from any collection
1082    pub fn get_any(&self, id: EntityId) -> Option<(String, UnifiedEntity)> {
1083        // Sharded LRU probe: hits / misses are counted by `EntityCache::get`,
1084        // so external observers can read `entity_cache_hit_rate()` without
1085        // any extra wiring on the call site.
1086        if let Some(cached) = self.entity_cache.get(id.raw()) {
1087            return Some(cached);
1088        }
1089
1090        // Cache miss — fall back to the full collection scan, then memoise.
1091        let collections = self.collections.read();
1092        for (name, manager) in collections.iter() {
1093            if let Some(entity) = manager.get(id) {
1094                let result = (name.clone(), entity);
1095                // Drop the collections read guard before taking any cache
1096                // lock — the cache shards are independent, but releasing
1097                // here keeps `collections` contention low.
1098                drop(collections);
1099                self.entity_cache.insert(id.raw(), result.clone());
1100                return Some(result);
1101            }
1102        }
1103        None
1104    }
1105
1106    /// Hit rate of the store's entity cache (`get_any` lookups).
1107    ///
1108    /// Returns `None` until the cache has served at least one lookup.
1109    /// Exposed for observability — e.g. dashboards distinguishing graph
1110    /// workloads (high hit rate) from OLTP DML (≈ 0 % hit rate).
1111    pub fn entity_cache_hit_rate(&self) -> Option<f64> {
1112        self.entity_cache.hit_rate()
1113    }
1114
1115    /// Snapshot of cache hit / miss / eviction counters and current size.
1116    pub fn entity_cache_stats(&self) -> super::super::entity_cache::EntityCacheStats {
1117        self.entity_cache.stats()
1118    }
1119
1120    /// Delete an entity
1121    pub fn delete(&self, collection: &str, id: EntityId) -> Result<bool, StoreError> {
1122        // Invalidate entity cache (read-lock probe; no write lock when cold).
1123        self.entity_cache.remove(id.raw());
1124        let manager = self
1125            .get_collection(collection)
1126            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1127
1128        let deleted = manager.delete(id)?;
1129        if !deleted {
1130            return Ok(false);
1131        }
1132
1133        // Remove from B-tree index if active
1134        let mut registry_dirty = false;
1135        if self.pager.is_some() {
1136            let btree_indices = self.btree_indices.read();
1137            if let Some(btree) = btree_indices.get(collection) {
1138                let root_before = btree.root_page_id();
1139                let key = id.raw().to_be_bytes();
1140                let _ = btree.delete(&key);
1141                registry_dirty = root_before != btree.root_page_id();
1142            }
1143        }
1144
1145        // Remove cross-references
1146        self.unindex_cross_refs(id)?;
1147
1148        // Remove from graph label index
1149        self.remove_from_graph_label_index(collection, id);
1150
1151        if registry_dirty {
1152            self.mark_paged_registry_dirty();
1153        }
1154        self.finish_paged_write([StoreWalAction::DeleteEntityRecord {
1155            collection: collection.to_string(),
1156            entity_id: id.raw(),
1157        }])?;
1158
1159        Ok(true)
1160    }
1161
1162    pub fn delete_batch(
1163        &self,
1164        collection: &str,
1165        ids: &[EntityId],
1166    ) -> Result<Vec<EntityId>, StoreError> {
1167        if ids.is_empty() {
1168            return Ok(Vec::new());
1169        }
1170
1171        // Sharded invalidation. The bounded LRU's shard write lock is
1172        // only acquired when the shard actually carries one of these ids,
1173        // so the bench-typical zero-hit `delete_sequential` workload never
1174        // takes a write lock here at all.
1175        self.entity_cache.remove_many(ids.iter().map(|id| id.raw()));
1176
1177        let manager = self
1178            .get_collection(collection)
1179            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1180
1181        let deleted_ids = manager.delete_batch(ids)?;
1182        if deleted_ids.is_empty() {
1183            return Ok(deleted_ids);
1184        }
1185
1186        let mut registry_dirty = false;
1187        if self.pager.is_some() {
1188            let btree_indices = self.btree_indices.read();
1189            if let Some(btree) = btree_indices.get(collection) {
1190                let root_before = btree.root_page_id();
1191                for id in &deleted_ids {
1192                    let key = id.raw().to_be_bytes();
1193                    let _ = btree.delete(&key);
1194                }
1195                registry_dirty = root_before != btree.root_page_id();
1196            }
1197        }
1198
1199        self.unindex_cross_refs_batch(&deleted_ids)?;
1200        self.remove_from_graph_label_index_batch(collection, &deleted_ids);
1201        if registry_dirty {
1202            self.mark_paged_registry_dirty();
1203        }
1204        let actions = deleted_ids
1205            .iter()
1206            .map(|id| StoreWalAction::DeleteEntityRecord {
1207                collection: collection.to_string(),
1208                entity_id: id.raw(),
1209            })
1210            .collect::<Vec<_>>();
1211        self.finish_paged_write(actions)?;
1212
1213        Ok(deleted_ids)
1214    }
1215
1216    /// Set metadata for an entity
1217    pub fn set_metadata(
1218        &self,
1219        collection: &str,
1220        id: EntityId,
1221        metadata: Metadata,
1222    ) -> Result<(), StoreError> {
1223        let manager = self
1224            .get_collection(collection)
1225            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1226
1227        manager.set_metadata(id, metadata)?;
1228        if let Some(entity) = manager.get(id) {
1229            self.persist_entities_to_pager(collection, std::slice::from_ref(&entity))?;
1230        }
1231        Ok(())
1232    }
1233
1234    /// Get metadata for an entity
1235    pub fn get_metadata(&self, collection: &str, id: EntityId) -> Option<Metadata> {
1236        self.get_collection(collection)?.get_metadata(id)
1237    }
1238
1239    /// Add a cross-reference between entities
1240    pub fn add_cross_ref(
1241        &self,
1242        source_collection: &str,
1243        source_id: EntityId,
1244        target_collection: &str,
1245        target_id: EntityId,
1246        ref_type: RefType,
1247        weight: f32,
1248    ) -> Result<(), StoreError> {
1249        // Check source exists
1250        let source_manager = self
1251            .get_collection(source_collection)
1252            .ok_or_else(|| StoreError::CollectionNotFound(source_collection.to_string()))?;
1253
1254        if source_manager.get(source_id).is_none() {
1255            return Err(StoreError::EntityNotFound(source_id));
1256        }
1257
1258        // Check target exists
1259        let target_manager = self
1260            .get_collection(target_collection)
1261            .ok_or_else(|| StoreError::CollectionNotFound(target_collection.to_string()))?;
1262
1263        if target_manager.get(target_id).is_none() {
1264            return Err(StoreError::EntityNotFound(target_id));
1265        }
1266
1267        // Check limits
1268        let current_refs = self
1269            .cross_refs
1270            .read()
1271            .get(&source_id)
1272            .map_or(0, |v| v.len());
1273
1274        if current_refs >= self.config.max_cross_refs {
1275            return Err(StoreError::TooManyRefs(source_id));
1276        }
1277
1278        let mut registry_dirty = false;
1279        {
1280            let mut forward = self.cross_refs.write();
1281            let refs = forward.entry(source_id).or_default();
1282            let inserted = !refs.iter().any(|(id, kind, coll)| {
1283                *id == target_id && *kind == ref_type && coll == target_collection
1284            });
1285            if inserted {
1286                refs.push((target_id, ref_type, target_collection.to_string()));
1287                registry_dirty = true;
1288            }
1289        }
1290
1291        {
1292            let mut reverse = self.reverse_refs.write();
1293            let refs = reverse.entry(target_id).or_default();
1294            let inserted = !refs.iter().any(|(id, kind, coll)| {
1295                *id == source_id && *kind == ref_type && coll == source_collection
1296            });
1297            if inserted {
1298                refs.push((source_id, ref_type, source_collection.to_string()));
1299                registry_dirty = true;
1300            }
1301        }
1302
1303        if let Some(mut entity) = source_manager.get(source_id) {
1304            if !entity.cross_refs().iter().any(|xref| {
1305                xref.target == target_id
1306                    && xref.ref_type == ref_type
1307                    && xref.target_collection == target_collection
1308            }) {
1309                let cross_ref = CrossRef::with_weight(
1310                    source_id,
1311                    target_id,
1312                    target_collection,
1313                    ref_type,
1314                    weight,
1315                );
1316                entity.add_cross_ref(cross_ref);
1317                let _ = source_manager.update(entity.clone());
1318                registry_dirty = true;
1319                self.persist_entities_to_pager(source_collection, std::slice::from_ref(&entity))?;
1320            }
1321        }
1322
1323        if registry_dirty {
1324            self.mark_paged_registry_dirty();
1325            if matches!(
1326                self.config.durability_mode,
1327                crate::api::DurabilityMode::Strict
1328            ) {
1329                self.flush_paged_state()?;
1330            }
1331        }
1332
1333        Ok(())
1334    }
1335
1336    /// Get cross-references from an entity
1337    pub fn get_refs_from(&self, id: EntityId) -> Vec<(EntityId, RefType, String)> {
1338        self.cross_refs.read().get(&id).cloned().unwrap_or_default()
1339    }
1340
1341    /// Get cross-references to an entity
1342    pub fn get_refs_to(&self, id: EntityId) -> Vec<(EntityId, RefType, String)> {
1343        self.reverse_refs
1344            .read()
1345            .get(&id)
1346            .cloned()
1347            .unwrap_or_default()
1348    }
1349
1350    /// Expand cross-references to get related entities
1351    pub fn expand_refs(
1352        &self,
1353        id: EntityId,
1354        depth: u32,
1355        ref_types: Option<&[RefType]>,
1356    ) -> Vec<(UnifiedEntity, u32, RefType)> {
1357        let mut results = Vec::new();
1358        let mut visited = std::collections::HashSet::new();
1359        visited.insert(id);
1360
1361        self.expand_refs_recursive(id, depth, ref_types, &mut visited, &mut results, 1);
1362
1363        results
1364    }
1365
1366    fn expand_refs_recursive(
1367        &self,
1368        id: EntityId,
1369        max_depth: u32,
1370        ref_types: Option<&[RefType]>,
1371        visited: &mut std::collections::HashSet<EntityId>,
1372        results: &mut Vec<(UnifiedEntity, u32, RefType)>,
1373        current_depth: u32,
1374    ) {
1375        if current_depth > max_depth {
1376            return;
1377        }
1378
1379        for (target_id, ref_type, target_collection) in self.get_refs_from(id) {
1380            if visited.contains(&target_id) {
1381                continue;
1382            }
1383
1384            if let Some(types) = ref_types {
1385                if !types.contains(&ref_type) {
1386                    continue;
1387                }
1388            }
1389
1390            visited.insert(target_id);
1391
1392            if let Some(entity) = self.get(&target_collection, target_id) {
1393                results.push((entity, current_depth, ref_type));
1394
1395                // Recurse
1396                self.expand_refs_recursive(
1397                    target_id,
1398                    max_depth,
1399                    ref_types,
1400                    visited,
1401                    results,
1402                    current_depth + 1,
1403                );
1404            }
1405        }
1406    }
1407
1408    /// Index cross-references from an entity
1409    pub(crate) fn index_cross_refs(
1410        &self,
1411        entity: &UnifiedEntity,
1412        collection: &str,
1413    ) -> Result<(), StoreError> {
1414        let mut registry_dirty = false;
1415        for cross_ref in entity.cross_refs() {
1416            if cross_ref.target_collection.is_empty() {
1417                continue;
1418            }
1419            {
1420                let mut forward = self.cross_refs.write();
1421                let refs = forward.entry(cross_ref.source).or_default();
1422                let inserted = !refs.iter().any(|(id, kind, coll)| {
1423                    *id == cross_ref.target
1424                        && *kind == cross_ref.ref_type
1425                        && coll == &cross_ref.target_collection
1426                });
1427                if inserted {
1428                    refs.push((
1429                        cross_ref.target,
1430                        cross_ref.ref_type,
1431                        cross_ref.target_collection.clone(),
1432                    ));
1433                    registry_dirty = true;
1434                }
1435            }
1436
1437            {
1438                let mut reverse = self.reverse_refs.write();
1439                let refs = reverse.entry(cross_ref.target).or_default();
1440                let inserted = !refs.iter().any(|(id, kind, coll)| {
1441                    *id == cross_ref.source && *kind == cross_ref.ref_type && coll == collection
1442                });
1443                if inserted {
1444                    refs.push((cross_ref.source, cross_ref.ref_type, collection.to_string()));
1445                    registry_dirty = true;
1446                }
1447            }
1448        }
1449
1450        if registry_dirty {
1451            self.mark_paged_registry_dirty();
1452        }
1453
1454        Ok(())
1455    }
1456
1457    /// Remove cross-references for an entity
1458    pub(crate) fn unindex_cross_refs(&self, id: EntityId) -> Result<(), StoreError> {
1459        // Remove forward refs
1460        self.cross_refs.write().remove(&id);
1461
1462        // Remove from reverse refs (scan all)
1463        let mut reverse = self.reverse_refs.write();
1464        for refs in reverse.values_mut() {
1465            refs.retain(|(source, _, _)| *source != id);
1466        }
1467        reverse.remove(&id);
1468        self.mark_paged_registry_dirty();
1469
1470        Ok(())
1471    }
1472
1473    pub(crate) fn unindex_cross_refs_batch(&self, ids: &[EntityId]) -> Result<(), StoreError> {
1474        if ids.is_empty() {
1475            return Ok(());
1476        }
1477
1478        let id_set: std::collections::HashSet<EntityId> = ids.iter().copied().collect();
1479
1480        // Read-only probe: under steady-state delete workloads most rows have
1481        // no cross-refs at all, so both maps are entirely cold for the batch.
1482        // Acquiring the write lock to scan `reverse_refs.values_mut()` over an
1483        // unrelated dictionary was the dominant cost in #85's `delete_sequential`
1484        // profile. The read-lock probe below is O(|ids|) hashmap lookups against
1485        // each map, vs. O(|reverse_refs|) for the write-path scan it replaces.
1486        //
1487        // Decision: skip the write path iff
1488        //   - no deleted id is a key in `cross_refs`  (no outbound refs to drop,
1489        //     and so no `(deleted_id, _, _)` source entry exists anywhere in
1490        //     `reverse_refs.values()` either — the two maps are kept in sync by
1491        //     `add_cross_ref`), AND
1492        //   - no deleted id is a key in `reverse_refs` (no inbound refs to drop).
1493        let needs_forward_cleanup = {
1494            let forward = self.cross_refs.read();
1495            id_set.iter().any(|id| forward.contains_key(id))
1496        };
1497        let needs_reverse_cleanup = {
1498            let reverse = self.reverse_refs.read();
1499            id_set.iter().any(|id| reverse.contains_key(id))
1500        };
1501
1502        if !needs_forward_cleanup && !needs_reverse_cleanup {
1503            self.unindex_cross_refs_fast_path
1504                .fetch_add(1, Ordering::Relaxed);
1505            return Ok(());
1506        }
1507
1508        if needs_forward_cleanup {
1509            let mut forward = self.cross_refs.write();
1510            for id in &id_set {
1511                forward.remove(id);
1512            }
1513        }
1514
1515        if needs_reverse_cleanup || needs_forward_cleanup {
1516            // The inner `values_mut()` scan is only needed when at least one
1517            // deleted id was a *source* somewhere — which, by the invariant
1518            // above, can only happen when it had outbound forward refs.
1519            let mut reverse = self.reverse_refs.write();
1520            if needs_forward_cleanup {
1521                for refs in reverse.values_mut() {
1522                    refs.retain(|(source, _, _)| !id_set.contains(source));
1523                }
1524            }
1525            reverse.retain(|target, refs| !id_set.contains(target) && !refs.is_empty());
1526        }
1527        self.mark_paged_registry_dirty();
1528
1529        Ok(())
1530    }
1531
1532    /// Test/observability hook: number of times `unindex_cross_refs_batch`
1533    /// took the read-only fast path. Used to pin the early-exit in tests
1534    /// and as a cheap signal in delete-heavy benchmarks.
1535    pub fn unindex_cross_refs_fast_path_hits(&self) -> u64 {
1536        self.unindex_cross_refs_fast_path.load(Ordering::Relaxed)
1537    }
1538
1539    /// Query across all collections with a filter
1540    pub fn query_all<F>(&self, filter: F) -> Vec<(String, UnifiedEntity)>
1541    where
1542        F: Fn(&UnifiedEntity) -> bool + Clone + Send + Sync,
1543    {
1544        // Issue #815 — snapshot the (name, manager) handles under a brief
1545        // read lock, then release the `collections` lock *before* the
1546        // potentially long per-collection scan. Holding `collections.read()`
1547        // across a full-store scan let a large `/catalog` snapshot block (and
1548        // be blocked by) the replica apply loop's collection-creation writes:
1549        // under parking_lot a writer waiting behind the long read parks every
1550        // subsequent reader, so `/catalog` and readiness wedged during a big
1551        // WAL re-apply. The managers are `Arc`-shared with their own internal
1552        // locks, so scanning them off the map lock is safe and keeps the HTTP
1553        // surface responsive.
1554        let pairs: Vec<(String, Arc<SegmentManager>)> = {
1555            let collections = self.collections.read();
1556            collections
1557                .iter()
1558                .map(|(name, mgr)| (name.clone(), Arc::clone(mgr)))
1559                .collect()
1560        };
1561
1562        let use_parallel = pairs.len() > 1 && crate::runtime::SystemInfo::should_parallelize();
1563        if !use_parallel {
1564            // Single collection — no parallelism overhead
1565            return pairs
1566                .into_iter()
1567                .flat_map(|(name, mgr)| {
1568                    mgr.query_all(filter.clone())
1569                        .into_iter()
1570                        .map(move |e| (name.clone(), e))
1571                })
1572                .collect();
1573        }
1574
1575        // Multiple collections — scan in parallel
1576        let filter_ref = &filter;
1577        let collection_results: Vec<Vec<(String, UnifiedEntity)>> = std::thread::scope(|s| {
1578            pairs
1579                .iter()
1580                .map(|(name, manager)| {
1581                    let name = (*name).clone();
1582                    s.spawn(move || {
1583                        manager
1584                            .query_all(|e| filter_ref(e))
1585                            .into_iter()
1586                            .map(|e| (name.clone(), e))
1587                            .collect::<Vec<_>>()
1588                    })
1589                })
1590                .collect::<Vec<_>>()
1591                .into_iter()
1592                .map(|h| h.join().unwrap_or_default())
1593                .collect()
1594        });
1595
1596        collection_results.into_iter().flatten().collect()
1597    }
1598
1599    /// Scan every collection under an explicit MVCC snapshot.
1600    pub fn scan<F>(
1601        &self,
1602        snapshot: Option<&crate::runtime::impl_core::SnapshotContext>,
1603        filter: F,
1604    ) -> Vec<(String, UnifiedEntity)>
1605    where
1606        F: Fn(&UnifiedEntity) -> bool + Clone + Send + Sync,
1607    {
1608        let pairs: Vec<(String, Arc<SegmentManager>)> = {
1609            let collections = self.collections.read();
1610            collections
1611                .iter()
1612                .map(|(name, manager)| (name.clone(), Arc::clone(manager)))
1613                .collect()
1614        };
1615
1616        let use_parallel = pairs.len() > 1 && crate::runtime::SystemInfo::should_parallelize();
1617        if !use_parallel {
1618            return pairs
1619                .into_iter()
1620                .flat_map(|(name, manager)| {
1621                    manager
1622                        .scan(snapshot, filter.clone())
1623                        .into_iter()
1624                        .map(move |entity| (name.clone(), entity))
1625                })
1626                .collect();
1627        }
1628
1629        let filter_ref = &filter;
1630        let collection_results: Vec<Vec<(String, UnifiedEntity)>> = std::thread::scope(|scope| {
1631            pairs
1632                .iter()
1633                .map(|(name, manager)| {
1634                    let name = name.clone();
1635                    scope.spawn(move || {
1636                        manager
1637                            .scan(snapshot, |entity| filter_ref(entity))
1638                            .into_iter()
1639                            .map(|entity| (name.clone(), entity))
1640                            .collect::<Vec<_>>()
1641                    })
1642                })
1643                .collect::<Vec<_>>()
1644                .into_iter()
1645                .map(|handle| handle.join().unwrap_or_default())
1646                .collect()
1647        });
1648
1649        collection_results.into_iter().flatten().collect()
1650    }
1651
1652    /// Filter by metadata across all collections
1653    pub fn filter_metadata_all(
1654        &self,
1655        filters: &[(String, MetadataFilter)],
1656    ) -> Vec<(String, EntityId)> {
1657        let mut results = Vec::new();
1658        let collections = self.collections.read();
1659
1660        for (name, manager) in collections.iter() {
1661            for id in manager.filter_metadata(filters) {
1662                results.push((name.clone(), id));
1663            }
1664        }
1665
1666        results
1667    }
1668
1669    /// Get statistics
1670    pub fn stats(&self) -> StoreStats {
1671        // Issue #815 — snapshot the manager handles, drop the `collections`
1672        // read lock, then gather per-collection stats off the map lock. The
1673        // readiness probe (via `health()`) walks `stats()`, so holding the
1674        // store lock across a full-store stats scan was part of the same
1675        // wedge that froze `/catalog` during a large replica re-apply.
1676        let pairs: Vec<(String, Arc<SegmentManager>)> = {
1677            let collections = self.collections.read();
1678            collections
1679                .iter()
1680                .map(|(name, mgr)| (name.clone(), Arc::clone(mgr)))
1681                .collect()
1682        };
1683
1684        let mut stats = StoreStats {
1685            collection_count: pairs.len(),
1686            ..Default::default()
1687        };
1688
1689        for (name, manager) in &pairs {
1690            let manager_stats = manager.stats();
1691            stats.total_entities += manager_stats.total_entities;
1692            stats.total_memory_bytes += manager_stats.total_memory_bytes;
1693            stats.collections.insert(name.clone(), manager_stats);
1694        }
1695
1696        stats
1697    }
1698
1699    /// Run maintenance on all collections
1700    pub fn run_maintenance(&self) -> Result<(), StoreError> {
1701        let collections = self.collections.read();
1702        for manager in collections.values() {
1703            manager.run_maintenance()?;
1704        }
1705        Ok(())
1706    }
1707
1708    /// Bytes pressure consolidation could plausibly return across collections.
1709    pub fn reclaimable_segment_bytes(&self) -> u64 {
1710        let collections = self.collections.read();
1711        collections
1712            .values()
1713            .map(|manager| manager.reclaimable_bytes())
1714            .fold(0, u64::saturating_add)
1715    }
1716
1717    /// Advance pressure-triggered consolidation by one paced tick per
1718    /// collection. Returns true if any collection started or advanced a run.
1719    pub fn pressure_consolidation_tick(&self) -> bool {
1720        let managers: Vec<_> = self.collections.read().values().cloned().collect();
1721        // A plain loop, not `any()`: every collection must receive its tick —
1722        // short-circuiting on the first advanced run would starve the rest.
1723        let mut advanced = false;
1724        for manager in &managers {
1725            advanced |= manager.pressure_consolidation_tick();
1726        }
1727        advanced
1728    }
1729}
1730
1731/// Flatten a JSON value into dot-notation key-value pairs for red_config.
1732fn flatten_config_json(
1733    prefix: &str,
1734    value: &crate::serde_json::Value,
1735    out: &mut Vec<(String, reddb_types::Value)>,
1736) {
1737    use reddb_types::Value;
1738    match value {
1739        crate::serde_json::Value::Object(map) => {
1740            for (k, v) in map {
1741                let key = if prefix.is_empty() {
1742                    k.clone()
1743                } else {
1744                    format!("{prefix}.{k}")
1745                };
1746                flatten_config_json(&key, v, out);
1747            }
1748        }
1749        crate::serde_json::Value::String(s) => {
1750            out.push((prefix.to_string(), Value::text(s.clone())));
1751        }
1752        crate::serde_json::Value::Integer(n) => {
1753            out.push((prefix.to_string(), Value::Integer(*n)));
1754        }
1755        crate::serde_json::Value::Number(n) => {
1756            if n.fract().abs() < f64::EPSILON {
1757                out.push((prefix.to_string(), Value::UnsignedInteger(*n as u64)));
1758            } else {
1759                out.push((prefix.to_string(), Value::Float(*n)));
1760            }
1761        }
1762        crate::serde_json::Value::Decimal(n) => {
1763            out.push((prefix.to_string(), Value::DecimalText(n.clone())));
1764        }
1765        crate::serde_json::Value::Bool(b) => {
1766            out.push((prefix.to_string(), Value::Boolean(*b)));
1767        }
1768        crate::serde_json::Value::Null => {
1769            out.push((prefix.to_string(), Value::Null));
1770        }
1771        crate::serde_json::Value::Array(arr) => {
1772            let json_str = crate::serde_json::to_string(value).unwrap_or_default();
1773            out.push((prefix.to_string(), Value::text(json_str)));
1774        }
1775    }
1776}