Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

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