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