Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

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