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