Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

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