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