Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

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