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.immutable_paged_base()?;
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.immutable_paged_base()?
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.immutable_paged_base()?;
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.immutable_paged_base()?
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        let immutable = self.immutable_paged_base()?;
682        if let Some(pager) = self
683            .pager
684            .as_ref()
685            .filter(|_| !Self::deferred_store_wal_capture_active() && !immutable)
686        {
687            if let Some(entity) = manager.get(id) {
688                let mut btree_indices = self.btree_indices.write();
689                let btree = btree_indices
690                    .entry(collection.to_string())
691                    .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
692                let root_before = btree.root_page_id();
693
694                let key = id.raw().to_be_bytes();
695                let metadata = manager.get_metadata(id);
696                let value = Self::serialize_entity_record(
697                    &entity,
698                    metadata.as_ref(),
699                    self.format_version(),
700                );
701                // Ignore duplicate key errors (update scenario)
702                let _ = btree.insert(&key, &value);
703                registry_dirty = root_before != btree.root_page_id();
704            }
705        }
706
707        // Index cross-references if enabled
708        if self.config.auto_index_refs {
709            if let Some(entity) = manager.get(id) {
710                self.index_cross_refs(&entity, collection)?;
711            }
712        }
713
714        // Perf: skip WAL-action construction when the store is
715        // pagerless. For in-memory benchmarks this saved another
716        // `manager.get(id)` + `serialize_entity_record` per call.
717        if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
718            let actions = manager
719                .get(id)
720                .map(|entity| {
721                    let metadata = manager.get_metadata(id);
722                    vec![StoreWalAction::upsert_entity(
723                        collection,
724                        &entity,
725                        metadata.as_ref(),
726                        self.format_version(),
727                    )]
728                })
729                .unwrap_or_default();
730            if registry_dirty {
731                self.mark_paged_registry_dirty();
732            }
733            self.finish_paged_write(actions)?;
734        } else {
735            self.note_in_memory_result_mutation(collection)?;
736        }
737
738        Ok(id)
739    }
740
741    /// Turbo bulk insert — optimized fast path.
742    ///
743    /// Single lock for the entire batch. Skips bloom filter,
744    /// context index, and cross-ref indexing. B-tree writes are batched.
745    pub fn bulk_insert(
746        &self,
747        collection: &str,
748        mut entities: Vec<UnifiedEntity>,
749    ) -> Result<Vec<EntityId>, StoreError> {
750        let collection = Self::canonical_collection(collection);
751        for entity in &mut entities {
752            entity.canonicalize_system_collection_names();
753            super::config_keys::validate_config_entity(collection, entity)?;
754        }
755        // A transaction can begin after checkpoint visibility is frozen.
756        // Fence the entire row batch, including its captured or committed WAL.
757        let _publication = self.vault_publication_lock.lock();
758        // REDDB_BULK_TIMING=1 prints a per-call breakdown of the bulk
759        // insert path to stderr. Off by default — used by the reddb
760        // benchmark harness to locate ingest bottlenecks.
761        let trace = matches!(
762            std::env::var("REDDB_BULK_TIMING").ok().as_deref(),
763            Some("1") | Some("true") | Some("on")
764        );
765        let t_start = std::time::Instant::now();
766        let n = entities.len();
767        let manager = self.get_or_create_collection(collection);
768        let t_get_coll = t_start.elapsed();
769
770        // Assign IDs and per-table row_ids before serialization. Bulk
771        // insert must follow the same global ID semantics as
772        // insert()/insert_auto(). Reserve ID ranges in single fetch_add
773        // calls instead of one-per-entity. The overwhelming common case
774        // is "every entity comes in with id==0 and kind==TableRow with
775        // row_id==0" (the wire bulk insert path). Any entity that
776        // already carries an id or row_id falls back to the individual
777        // register_* path to preserve those semantics exactly.
778        let t0 = std::time::Instant::now();
779        let n_missing_entity_ids = entities.iter().filter(|e| e.id.raw() == 0).count() as u64;
780        let n_missing_row_ids = entities
781            .iter()
782            .filter(|e| matches!(e.kind, EntityKind::TableRow { row_id: 0, .. }))
783            .count() as u64;
784        let mut entity_id_range = if n_missing_entity_ids > 0 {
785            self.reserve_entity_ids(n_missing_entity_ids)
786        } else {
787            0..0
788        };
789        let mut row_id_range = if n_missing_row_ids > 0 {
790            manager.reserve_row_ids(n_missing_row_ids)
791        } else {
792            0..0
793        };
794        for entity in &mut entities {
795            if entity.id.raw() == 0 {
796                let next = entity_id_range
797                    .next()
798                    .expect("reserved entity-id range exhausted");
799                entity.id = EntityId::new(next);
800            } else {
801                self.register_entity_id(entity.id);
802            }
803            if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
804                if *row_id == 0 {
805                    *row_id = row_id_range
806                        .next()
807                        .expect("reserved row-id range exhausted");
808                } else {
809                    manager.register_row_id(*row_id);
810                }
811            }
812            entity.ensure_table_logical_id();
813        }
814        let t_assign_ids = t0.elapsed();
815
816        // Capture graph node labels before entities are moved into the segment manager
817        let graph_labels: Vec<Option<(String, EntityId)>> = entities
818            .iter()
819            .map(|e| {
820                if let EntityKind::GraphNode(ref node) = e.kind {
821                    Some((node.label.clone(), e.id))
822                } else {
823                    None
824                }
825            })
826            .collect();
827
828        // Move entities into segment before serializing their canonical image:
829        // bulk insertion assigns sequence identities used by checkpoint proofs.
830        let t0 = std::time::Instant::now();
831        let ids = manager.bulk_insert(entities)?;
832        let t_manager = t0.elapsed();
833        for id in &ids {
834            self.register_entity_id(*id);
835        }
836
837        // Serialize for B-tree and WAL from the installed segment rows.
838        // `serialize_entity_record` is pure; with `n >= 1024` the
839        // rayon dispatch cost is amortised and the 16-core serialize
840        // fan-out becomes the biggest single win on insert_bulk. For
841        // smaller batches, stay serial — micro-batches pay more in
842        // work-stealing overhead than they save.
843        let t0 = std::time::Instant::now();
844        let serialized: Option<Vec<(Vec<u8>, Vec<u8>)>> =
845            if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
846                let fv = self.format_version();
847                let serial_map = |id: &EntityId| {
848                    manager
849                        .get_with(*id, |entity| {
850                            (
851                                id.raw().to_be_bytes().to_vec(),
852                                Self::serialize_entity_record(entity, None, fv),
853                            )
854                        })
855                        .expect("bulk-inserted entity must remain in the fenced segment")
856                };
857                // Gate chosen to match the bench's `BULK_BATCH_SIZE=1000`.
858                // Rayon's dispatch overhead is ~30µs/batch — on a 15-col
859                // row serializing for ~40µs the break-even is around
860                // 200-300 rows on a 16-core host. Keep a safety margin
861                // at 512.
862                if ids.len() >= 512 {
863                    use rayon::prelude::*;
864                    Some(ids.par_iter().map(serial_map).collect())
865                } else {
866                    Some(ids.iter().map(serial_map).collect())
867                }
868            } else {
869                None
870            };
871        let t_serialize = t0.elapsed();
872
873        // Update graph label index for bulk-inserted GraphNode entities
874        for (label, entity_id) in graph_labels.iter().flatten() {
875            self.update_graph_label_index(collection, label, *entity_id);
876        }
877
878        // REDDB_BULK_SKIP_PERSIST_UNSAFE=1 skips the persistent B-tree index
879        // during bulk ingest.
880        //
881        // UNSAFE: for ephemeral benchmark containers ONLY.
882        // This flag is silently ignored when a pager (durable storage) is active.
883        // In persistent mode, bulk inserts ALWAYS write to the B-tree so the data
884        // survives a cold restart without any manual rebuild step.
885        //
886        // The flag is only honoured when self.pager is None (in-memory / ephemeral).
887        let skip_btree_requested = matches!(
888            std::env::var("REDDB_BULK_SKIP_PERSIST_UNSAFE")
889                .ok()
890                .as_deref(),
891            Some("1") | Some("true") | Some("on")
892        );
893        // Honour the flag only when there is no durable pager.
894        // If a pager exists we are in persistent mode → always persist.
895        let skip_btree = skip_btree_requested && self.pager.is_none();
896        if skip_btree_requested && !skip_btree {
897            // Flag was set but we ignored it because we have a real pager.
898            static IGNORED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
899            IGNORED.get_or_init(|| {
900                tracing::warn!(
901                    "REDDB_BULK_SKIP_PERSIST_UNSAFE set but durable pager is \
902                     active — flag ignored; bulk inserts will be persisted normally"
903                );
904            });
905        } else if skip_btree {
906            // Ephemeral mode and flag is active — warn once.
907            static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
908            WARNED.get_or_init(|| {
909                tracing::warn!(
910                    "REDDB_BULK_SKIP_PERSIST_UNSAFE set (ephemeral/no-pager mode) — \
911                     bulk inserts NOT durable; data will be lost on restart"
912                );
913            });
914        }
915
916        // Batch B-tree write from pre-serialized data.
917        // Uses sorted bulk insert: walks to a leaf once, appends many entries,
918        // writes each leaf exactly once per batch — O(N) instead of O(N²).
919        let mut t_btree_lock = std::time::Duration::ZERO;
920        let mut t_btree_insert = std::time::Duration::ZERO;
921        let mut t_flush = std::time::Duration::ZERO;
922        if !skip_btree
923            && !Self::deferred_store_wal_capture_active()
924            && !self.immutable_paged_base()?
925        {
926            if let (Some(pager), Some(batch)) = (&self.pager, serialized.as_ref()) {
927                let t0 = std::time::Instant::now();
928                let mut btree_indices = self.btree_indices.write();
929                let btree = btree_indices
930                    .entry(collection.to_string())
931                    .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
932                let root_before = btree.root_page_id();
933                t_btree_lock = t0.elapsed();
934
935                let t0 = std::time::Instant::now();
936                let _ = btree.bulk_insert_sorted(batch);
937                t_btree_insert = t0.elapsed();
938                let registry_dirty = root_before != btree.root_page_id();
939
940                let t0 = std::time::Instant::now();
941                if registry_dirty {
942                    self.mark_paged_registry_dirty();
943                }
944                t_flush = t0.elapsed();
945            }
946        }
947
948        // Fold 25k per-entity `UpsertEntityRecord` actions into one
949        // `BulkUpsertEntityRecords` — same on-disk semantics (replay
950        // applies each record by id), one `WalRecord::PageWrite`
951        // instead of N, and `record.clone()` drops out because we
952        // consume `serialized`.
953        let actions = serialized
954            .map(|batch| {
955                let records: Vec<Vec<u8>> =
956                    batch.into_iter().map(|(_key, record)| record).collect();
957                vec![StoreWalAction::BulkUpsertEntityRecords {
958                    collection: collection.to_string(),
959                    records,
960                }]
961            })
962            .unwrap_or_default();
963        if let Err(error) = self.finish_paged_write(actions) {
964            // Rows are already installed in the resident segment. A failed
965            // WAL publication must not leave a successful-looking scan.
966            for id in &ids {
967                self.apply_replayed_action(
968                    &StoreWalAction::DeleteEntityRecord {
969                        collection: collection.to_string(),
970                        entity_id: id.raw(),
971                    },
972                    true,
973                )?;
974            }
975            return Err(error);
976        }
977
978        if trace {
979            tracing::debug!(
980                n,
981                total = ?t_start.elapsed(),
982                get_coll = ?t_get_coll,
983                assign = ?t_assign_ids,
984                serialize = ?t_serialize,
985                manager = ?t_manager,
986                btree_lock = ?t_btree_lock,
987                btree = ?t_btree_insert,
988                flush = ?t_flush,
989                "bulk_insert timing"
990            );
991        }
992
993        Ok(ids)
994    }
995
996    /// Insert an entity, creating collection if needed
997    pub fn insert_auto(
998        &self,
999        collection: &str,
1000        entity: UnifiedEntity,
1001    ) -> Result<EntityId, StoreError> {
1002        let collection = Self::canonical_collection(collection);
1003        super::config_keys::validate_config_entity(collection, &entity)?;
1004        // Include the WAL append in the checkpoint publication fence.
1005        let mut publication = Some(self.vault_publication_lock.lock());
1006        let manager = self.get_or_create_collection(collection);
1007        let mut entity = entity;
1008        entity.canonicalize_system_collection_names();
1009        if entity.id.raw() == 0 {
1010            entity.id = self.next_entity_id();
1011        } else {
1012            self.register_entity_id(entity.id);
1013        }
1014        // Assign per-table sequential row_id if not set
1015        if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
1016            if *row_id == 0 {
1017                *row_id = manager.next_row_id();
1018            } else {
1019                manager.register_row_id(*row_id);
1020            }
1021        }
1022        entity.ensure_table_logical_id();
1023
1024        // Capture graph node label before entity is moved into the manager
1025        let graph_node_label: Option<String> = if let EntityKind::GraphNode(ref node) = entity.kind
1026        {
1027            Some(node.label.clone())
1028        } else {
1029            None
1030        };
1031        // Index into context index before consuming the entity
1032        self.resident
1033            .context_index
1034            .index_entity(collection, &entity);
1035
1036        // Serialize the canonical segment image after insertion: the segment
1037        // assigns its sequence identity. Borrow it instead of cloning payloads.
1038        let persist_record = self.pager.is_some() || self.config.embedded_wal_path.is_some();
1039        if self.config.auto_index_refs {
1040            self.index_cross_refs(&entity, collection)?;
1041        }
1042
1043        // #2376: `persist()` rebuilds every collection B-tree from the
1044        // manager under `btree_indices.write()`. Hold the read side from the
1045        // manager insert through the B-tree insert so a rebuild observes
1046        // both or neither. Otherwise the rebuild re-inserts this row from the
1047        // manager and one of the two B-tree inserts fails on a duplicate key,
1048        // or lands mid-rebuild and tears the tree.
1049        let paged_btree = if persist_record
1050            && !Self::deferred_store_wal_capture_active()
1051            && !self.immutable_paged_base()?
1052        {
1053            self.get_or_create_btree(collection)
1054        } else {
1055            None
1056        };
1057        let persist_fence = paged_btree
1058            .as_ref()
1059            .map(|_| self.btree_indices.read_recursive());
1060        let before = paged_btree.as_ref().map(|_| manager.checkpoint_revisions());
1061        let unversioned = entity.xmin == 0 && entity.xmax == 0;
1062
1063        let id = manager.insert(entity)?;
1064        let serialized_record = if persist_record {
1065            Some(
1066                manager
1067                    .get_with(id, |entity| {
1068                        Self::serialize_entity_record(entity, None, self.format_version())
1069                    })
1070                    .ok_or(StoreError::EntityNotFound(id))?,
1071            )
1072        } else {
1073            None
1074        };
1075        // `register_entity_id` already advances the atomic counter on
1076        // the allocation path above (`self.next_entity_id()`), so the
1077        // second call here is a no-op CAS loop on the hot path. Only
1078        // needed for the caller-supplied-id branch which happens via
1079        // the `register_entity_id` call on line 573.
1080
1081        // Update graph label index for GraphNode entities
1082        if let Some(ref label) = graph_node_label {
1083            self.update_graph_label_index(collection, label, id);
1084        }
1085
1086        let mut registry_dirty = false;
1087        if let (Some(btree), Some(record)) = (paged_btree.as_ref(), serialized_record.as_ref()) {
1088            let root_before = btree.root_page_id();
1089
1090            let key = id.raw().to_be_bytes();
1091            btree.insert(&key, record).map_err(|e| {
1092                StoreError::Io(std::io::Error::other(format!(
1093                    "B-tree insert error while inserting '{collection}'/{id}: {e}"
1094                )))
1095            })?;
1096            registry_dirty = root_before != btree.root_page_id();
1097        }
1098        drop(persist_fence);
1099
1100        // Perf: pagerless → skip WAL-action construction (saves a
1101        // third manager.get + entity serialize per insert). For
1102        // in-memory runtimes finish_paged_write is a no-op.
1103        if self.pager.is_some() || self.config.embedded_wal_path.is_some() {
1104            let actions = serialized_record
1105                .map(|record| {
1106                    vec![StoreWalAction::UpsertEntityRecord {
1107                        collection: collection.to_string(),
1108                        record,
1109                    }]
1110                })
1111                .unwrap_or_default();
1112            if registry_dirty {
1113                self.mark_paged_registry_dirty();
1114            }
1115            // Retain an embedded failure ticket until rollback completes, so
1116            // a checkpoint cannot publish the staged row in between.
1117            let mut pending = None;
1118            let persisted = (|| {
1119                pending = self.enqueue_grouped_actions(&actions)?;
1120                if let Some(pending) = pending.as_mut() {
1121                    drop(publication.take());
1122                    self.complete_grouped_actions(pending, &actions)
1123                } else {
1124                    self.finish_paged_write(actions)
1125                }
1126            })();
1127            if let Err(error) = persisted {
1128                let _publication = self.vault_publication_lock.lock();
1129                self.apply_replayed_action(
1130                    &StoreWalAction::DeleteEntityRecord {
1131                        collection: collection.to_string(),
1132                        entity_id: id.raw(),
1133                    },
1134                    true,
1135                )?;
1136                return Err(error);
1137            }
1138        } else {
1139            self.note_in_memory_result_mutation(collection)?;
1140        }
1141        if unversioned && !Self::deferred_store_wal_capture_active() {
1142            if let Some(before) = before {
1143                self.note_paged_insert(collection, &manager, &before, id);
1144            }
1145        }
1146        Ok(id)
1147    }
1148
1149    /// Get an entity from a collection
1150    ///
1151    /// Prefers the live SegmentManager view so reads after update/delete observe
1152    /// the current in-memory state even when the paged B-tree image has not been
1153    /// refreshed yet. Falls back to the B-tree image for recovery-oriented reads.
1154    pub fn get(&self, collection: &str, id: EntityId) -> Option<UnifiedEntity> {
1155        // Historical best-effort adapter. Authoritative host reads use try_get
1156        // so a failed cold read cannot become a successful "missing" result.
1157        self.try_get(collection, id, usize::MAX).ok().flatten()
1158    }
1159
1160    pub fn try_get(
1161        &self,
1162        collection: &str,
1163        id: EntityId,
1164        limit_bytes: usize,
1165    ) -> Result<Option<UnifiedEntity>, StoreError> {
1166        let Some(manager) = self.get_collection(collection) else {
1167            return Ok(None);
1168        };
1169        manager.try_get(id, limit_bytes)
1170    }
1171
1172    /// Resolve a table row by stable logical identity.
1173    ///
1174    /// Legacy rows use `logical_id == id`, so the physical-id lookup remains
1175    /// the fast path. Versioned rows may have a different physical id and fall
1176    /// back to a scan until the history-store/index slices add a dedicated map.
1177    pub fn get_table_row_by_logical_id(
1178        &self,
1179        collection: &str,
1180        logical_id: EntityId,
1181    ) -> Option<UnifiedEntity> {
1182        if let Some(entity) = self.get(collection, logical_id) {
1183            if matches!(entity.kind, EntityKind::TableRow { .. })
1184                && entity.logical_id() == logical_id
1185                && entity.xmax == 0
1186            {
1187                return Some(entity);
1188            }
1189        }
1190
1191        let manager = self.get_collection(collection)?;
1192        let mut matches = manager.query_all(|entity| {
1193            matches!(entity.kind, EntityKind::TableRow { .. }) && entity.logical_id() == logical_id
1194        });
1195        matches
1196            .iter()
1197            .find(|entity| entity.xmax == 0)
1198            .cloned()
1199            .or_else(|| matches.pop())
1200    }
1201
1202    pub fn table_row_versions_by_logical_id(
1203        &self,
1204        collection: &str,
1205        logical_id: EntityId,
1206    ) -> Vec<UnifiedEntity> {
1207        self.resident
1208            .table_row_versions_by_logical_id(collection, logical_id)
1209    }
1210
1211    pub(crate) fn try_for_each_table_row_version(
1212        &self,
1213        collection: &str,
1214        logical_id: EntityId,
1215        mut visit: impl FnMut(&UnifiedEntity) -> bool,
1216    ) -> Result<(), StoreError> {
1217        let Some(manager) = self.get_collection(collection) else {
1218            return Ok(());
1219        };
1220        manager.try_for_each_entity(usize::MAX, |entity| {
1221            !matches!(entity.kind, EntityKind::TableRow { .. })
1222                || entity.logical_id() != logical_id
1223                || visit(entity)
1224        })
1225    }
1226
1227    pub(crate) fn try_get_table_row_by_logical_id(
1228        &self,
1229        collection: &str,
1230        logical_id: EntityId,
1231    ) -> Result<Option<UnifiedEntity>, StoreError> {
1232        let Some(manager) = self.get_collection(collection) else {
1233            return Ok(None);
1234        };
1235        if let Some(entity) = manager.try_get(logical_id, usize::MAX)? {
1236            if matches!(entity.kind, EntityKind::TableRow { .. })
1237                && entity.logical_id() == logical_id
1238                && entity.xmax == 0
1239            {
1240                return Ok(Some(entity));
1241            }
1242        }
1243        // Preserve the current-row policy: first live version, otherwise the
1244        // last matching physical version. Retain only one owned candidate.
1245        let mut selected = None;
1246        self.try_for_each_table_row_version(collection, logical_id, |entity| {
1247            selected = Some(entity.clone());
1248            entity.xmax != 0
1249        })?;
1250        manager.restore_owned_row_schemas(selected.iter_mut());
1251        Ok(selected)
1252    }
1253
1254    pub fn vacuum_mvcc_history(
1255        &self,
1256        collection: &str,
1257        cutoff_xid: u64,
1258    ) -> Result<MvccVacuumStats, StoreError> {
1259        let manager = self
1260            .get_collection(collection)
1261            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1262        let mut logical = HashMap::<EntityId, (bool, u64)>::new();
1263        manager.for_each_entity(|entity| {
1264            if !matches!(entity.kind, EntityKind::TableRow { .. }) {
1265                return true;
1266            }
1267            let entry = logical.entry(entity.logical_id()).or_insert((false, 0));
1268            if entity.xmax == 0 {
1269                entry.0 = true;
1270            }
1271            entry.1 = entry.1.max(entity.xmin);
1272            true
1273        });
1274
1275        let mut stats = MvccVacuumStats::default();
1276        let mut reclaim_ids = Vec::new();
1277        manager.for_each_entity(|entity| {
1278            if !matches!(entity.kind, EntityKind::TableRow { .. }) || entity.xmax == 0 {
1279                return true;
1280            }
1281            stats.scanned_versions += 1;
1282            let (has_live_version, max_xmin) = logical
1283                .get(&entity.logical_id())
1284                .copied()
1285                .unwrap_or((false, entity.xmin));
1286            let is_delete_tombstone = !has_live_version && entity.xmin == max_xmin;
1287            if entity.xmax < cutoff_xid {
1288                stats.reclaimed_versions += 1;
1289                if is_delete_tombstone {
1290                    stats.reclaimed_tombstones += 1;
1291                } else {
1292                    stats.reclaimed_history_versions += 1;
1293                }
1294                reclaim_ids.push(entity.id);
1295            } else {
1296                stats.retained_versions += 1;
1297                if is_delete_tombstone {
1298                    stats.retained_tombstones += 1;
1299                } else {
1300                    stats.retained_history_versions += 1;
1301                }
1302            }
1303            true
1304        });
1305
1306        if !reclaim_ids.is_empty() {
1307            self.delete_batch(collection, &reclaim_ids)?;
1308        }
1309        Ok(stats)
1310    }
1311
1312    pub(crate) fn install_versioned_table_row_update(
1313        &self,
1314        collection: &str,
1315        old_version: UnifiedEntity,
1316        mut new_version: UnifiedEntity,
1317        metadata: Option<&Metadata>,
1318    ) -> Result<(), StoreError> {
1319        super::config_keys::validate_config_entity(collection, &new_version)?;
1320        let manager = self
1321            .get_collection(collection)
1322            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1323
1324        let old_id = old_version.id;
1325        let new_id = new_version.id;
1326        let inherited_metadata = metadata.cloned().or_else(|| manager.get_metadata(old_id));
1327
1328        self.resident.entity_cache.remove(old_id.raw());
1329        self.resident.entity_cache.remove(new_id.raw());
1330        manager.update(old_version.clone())?;
1331        #[cfg(test)]
1332        if let Some(hook) = self.test_versioned_update_after_old.lock().clone() {
1333            hook();
1334        }
1335        #[cfg(test)]
1336        if self
1337            .test_fail_versioned_update_phase
1338            .compare_exchange(1, 0, Ordering::SeqCst, Ordering::SeqCst)
1339            .is_ok()
1340        {
1341            return Err(StoreError::Io(std::io::Error::other(
1342                "injected update failure after old version",
1343            )));
1344        }
1345        self.resident.context_index.remove_entity(old_id);
1346
1347        self.register_entity_id(new_version.id);
1348        if let EntityKind::TableRow { ref mut row_id, .. } = new_version.kind {
1349            if *row_id == 0 {
1350                *row_id = manager.next_row_id();
1351            } else {
1352                manager.register_row_id(*row_id);
1353            }
1354        }
1355        new_version.ensure_table_logical_id();
1356        manager.insert(new_version.clone())?;
1357        #[cfg(test)]
1358        if self
1359            .test_fail_versioned_update_phase
1360            .compare_exchange(2, 0, Ordering::SeqCst, Ordering::SeqCst)
1361            .is_ok()
1362        {
1363            return Err(StoreError::Io(std::io::Error::other(
1364                "injected update failure after new version",
1365            )));
1366        }
1367        if let Some(metadata) = inherited_metadata {
1368            manager.set_metadata(new_id, metadata)?;
1369        }
1370        self.resident
1371            .context_index
1372            .index_entity(collection, &new_version);
1373        if self.config.auto_index_refs {
1374            self.index_cross_refs(&new_version, collection)?;
1375        }
1376
1377        let old_metadata = manager.get_metadata(old_id);
1378        let new_metadata = manager.get_metadata(new_id);
1379        let fv = self.format_version();
1380        // The inserted segment assigns the sequence identity. Persist that
1381        // canonical image, rather than the caller's pre-insertion clone.
1382        let records = vec![
1383            manager
1384                .get_with(old_id, |entity| {
1385                    Self::serialize_entity_record(entity, old_metadata.as_ref(), fv)
1386                })
1387                .ok_or(StoreError::EntityNotFound(old_id))?,
1388            manager
1389                .get_with(new_id, |entity| {
1390                    Self::serialize_entity_record(entity, new_metadata.as_ref(), fv)
1391                })
1392                .ok_or(StoreError::EntityNotFound(new_id))?,
1393        ];
1394        self.finish_paged_write_for_projection(
1395            [StoreWalAction::BulkUpsertEntityRecords {
1396                collection: collection.to_string(),
1397                records,
1398            }],
1399            true,
1400        )?;
1401
1402        Ok(())
1403    }
1404
1405    /// Batch-fetch multiple entities from the same collection in minimal lock acquisitions.
1406    ///
1407    /// Preferred over N individual `get()` calls in indexed-scan loops (sorted index,
1408    /// bitmap, hash). Reduces lock acquisitions from N×3 to 2-3 total.
1409    /// Preserves input order: `result[i]` corresponds to `ids[i]`.
1410    pub fn get_batch(&self, collection: &str, ids: &[EntityId]) -> Vec<Option<UnifiedEntity>> {
1411        match self.get_collection(collection) {
1412            Some(manager) => manager.get_many(ids),
1413            None => vec![None; ids.len()],
1414        }
1415    }
1416
1417    /// Preserve I/O failures when fetching indexed query candidates.
1418    pub fn try_get_batch(
1419        &self,
1420        collection: &str,
1421        ids: &[EntityId],
1422        read_bytes_max: usize,
1423    ) -> Result<Vec<Option<UnifiedEntity>>, StoreError> {
1424        match self.get_collection(collection) {
1425            Some(manager) => manager.try_get_many(ids, read_bytes_max),
1426            None => Ok(vec![None; ids.len()]),
1427        }
1428    }
1429
1430    /// Get an entity from any collection
1431    pub fn get_any(&self, id: EntityId) -> Option<(String, UnifiedEntity)> {
1432        self.resident.get_any(id)
1433    }
1434
1435    /// Hit rate of the store's entity cache (`get_any` lookups).
1436    ///
1437    /// Returns `None` until the cache has served at least one lookup.
1438    /// Exposed for observability — e.g. dashboards distinguishing graph
1439    /// workloads (high hit rate) from OLTP DML (≈ 0 % hit rate).
1440    pub fn entity_cache_hit_rate(&self) -> Option<f64> {
1441        self.resident.entity_cache_hit_rate()
1442    }
1443
1444    /// Snapshot of cache hit / miss / eviction counters and current size.
1445    pub fn entity_cache_stats(&self) -> super::super::entity_cache::EntityCacheStats {
1446        self.resident.entity_cache_stats()
1447    }
1448
1449    /// Delete an entity
1450    pub fn delete(&self, collection: &str, id: EntityId) -> Result<bool, StoreError> {
1451        let collection = Self::canonical_collection(collection);
1452        let publication = self.vault_publication_lock.lock();
1453        // Invalidate entity cache (read-lock probe; no write lock when cold).
1454        self.resident.entity_cache.remove(id.raw());
1455        let manager = self
1456            .get_collection(collection)
1457            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1458
1459        let deleted = manager.delete(id)?;
1460        if !deleted {
1461            return Ok(false);
1462        }
1463
1464        // Remove from B-tree index if active
1465        let mut registry_dirty = false;
1466        if self.pager.is_some() && !self.is_read_only() && !self.immutable_paged_base()? {
1467            let btree_indices = self.btree_indices.read();
1468            if let Some(btree) = btree_indices.get(collection) {
1469                let root_before = btree.root_page_id();
1470                let key = id.raw().to_be_bytes();
1471                let _ = btree.delete(&key);
1472                registry_dirty = root_before != btree.root_page_id();
1473            }
1474        }
1475
1476        // Remove cross-references
1477        self.unindex_cross_refs(id)?;
1478
1479        // Remove from graph label index
1480        self.remove_from_graph_label_index(collection, id);
1481
1482        if registry_dirty {
1483            self.mark_paged_registry_dirty();
1484        }
1485        let actions = [StoreWalAction::DeleteEntityRecord {
1486            collection: collection.to_string(),
1487            entity_id: id.raw(),
1488        }];
1489        if let Some(mut pending) = self.enqueue_grouped_actions(&actions)? {
1490            drop(publication);
1491            self.complete_grouped_actions(&mut pending, &actions)?;
1492        } else {
1493            self.finish_paged_write(actions)?;
1494        }
1495
1496        Ok(true)
1497    }
1498
1499    pub fn delete_batch(
1500        &self,
1501        collection: &str,
1502        ids: &[EntityId],
1503    ) -> Result<Vec<EntityId>, StoreError> {
1504        let collection = Self::canonical_collection(collection);
1505        if ids.is_empty() {
1506            return Ok(Vec::new());
1507        }
1508        // Physical reclamation must publish its mutation and WAL record under
1509        // the same fence as checkpoint snapshots and WAL truncation.
1510        let _publication = self.vault_publication_lock.lock();
1511
1512        // Sharded invalidation. The bounded LRU's shard write lock is
1513        // only acquired when the shard actually carries one of these ids,
1514        // so the bench-typical zero-hit `delete_sequential` workload never
1515        // takes a write lock here at all.
1516        self.resident
1517            .entity_cache
1518            .remove_many(ids.iter().map(|id| id.raw()));
1519
1520        let manager = self
1521            .get_collection(collection)
1522            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1523
1524        let before = self.pager.as_ref().map(|_| manager.checkpoint_revisions());
1525        let deleted_ids = manager.delete_batch(ids)?;
1526        if deleted_ids.is_empty() {
1527            return Ok(deleted_ids);
1528        }
1529
1530        let mut registry_dirty = false;
1531        let mut updated_tree = false;
1532        if self.pager.is_some() && !self.is_read_only() && !self.immutable_paged_base()? {
1533            let btree_indices = self.btree_indices.read();
1534            if let Some(btree) = btree_indices.get(collection) {
1535                updated_tree = true;
1536                let root_before = btree.root_page_id();
1537                for id in &deleted_ids {
1538                    let key = id.raw().to_be_bytes();
1539                    // An aborted, never-published row is already absent. Its
1540                    // durable physical removal also leaves the tree in sync.
1541                    updated_tree &= btree.delete(&key).is_ok();
1542                }
1543                registry_dirty = root_before != btree.root_page_id();
1544            }
1545        }
1546
1547        self.unindex_cross_refs_batch(&deleted_ids)?;
1548        self.remove_from_graph_label_index_batch(collection, &deleted_ids);
1549        if registry_dirty {
1550            self.mark_paged_registry_dirty();
1551        }
1552        let actions = deleted_ids
1553            .iter()
1554            .map(|id| StoreWalAction::DeleteEntityRecord {
1555                collection: collection.to_string(),
1556                entity_id: id.raw(),
1557            })
1558            .collect::<Vec<_>>();
1559        self.finish_paged_write(actions)?;
1560
1561        if updated_tree {
1562            if let Some(before) = before {
1563                self.note_paged_delete(collection, &manager, &before, &deleted_ids);
1564            }
1565            // Paced GC must also pace publication: otherwise grouped WAL
1566            // workloads can retain detached pages until a distant checkpoint.
1567            // Publish roots and the deferred-page ledger without re-encoding
1568            // unchanged row payloads.
1569            self.flush_paged_registry()?;
1570        }
1571        Ok(deleted_ids)
1572    }
1573
1574    /// Set metadata for an entity
1575    pub fn set_metadata(
1576        &self,
1577        collection: &str,
1578        id: EntityId,
1579        metadata: Metadata,
1580    ) -> Result<(), StoreError> {
1581        let manager = self
1582            .get_collection(collection)
1583            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1584
1585        manager.set_metadata(id, metadata)?;
1586        if let Some(entity) = manager.get(id) {
1587            self.persist_entities_to_pager(collection, std::slice::from_ref(&entity))?;
1588        }
1589        Ok(())
1590    }
1591
1592    /// Get metadata for an entity
1593    pub fn get_metadata(&self, collection: &str, id: EntityId) -> Option<Metadata> {
1594        self.get_collection(collection)?.get_metadata(id)
1595    }
1596
1597    /// Fallible metadata access for authoritative reads and mutation preimages.
1598    pub fn try_get_metadata(
1599        &self,
1600        collection: &str,
1601        id: EntityId,
1602        limit_bytes: usize,
1603    ) -> Result<Option<Metadata>, StoreError> {
1604        let Some(manager) = self.get_collection(collection) else {
1605            return Ok(None);
1606        };
1607        manager.try_get_metadata(id, limit_bytes)
1608    }
1609
1610    /// Add a cross-reference between entities
1611    pub fn add_cross_ref(
1612        &self,
1613        source_collection: &str,
1614        source_id: EntityId,
1615        target_collection: &str,
1616        target_id: EntityId,
1617        ref_type: RefType,
1618        weight: f32,
1619    ) -> Result<(), StoreError> {
1620        let source_collection = Self::canonical_collection(source_collection);
1621        let target_collection = Self::canonical_collection(target_collection);
1622        let registry_dirty = self.resident.add_cross_ref_in_memory(
1623            source_collection,
1624            source_id,
1625            target_collection,
1626            target_id,
1627            ref_type,
1628            weight,
1629            self.config.max_cross_refs,
1630            |entity| {
1631                self.persist_entities_to_pager(source_collection, std::slice::from_ref(entity))
1632            },
1633        )?;
1634        if registry_dirty
1635            && matches!(
1636                self.config.durability_mode,
1637                crate::api::DurabilityMode::Strict
1638            )
1639        {
1640            self.flush_paged_state()?;
1641        }
1642        Ok(())
1643    }
1644
1645    /// Get cross-references from an entity
1646    pub fn get_refs_from(&self, id: EntityId) -> Vec<(EntityId, RefType, String)> {
1647        self.resident.get_refs_from(id)
1648    }
1649
1650    /// Get cross-references to an entity
1651    pub fn get_refs_to(&self, id: EntityId) -> Vec<(EntityId, RefType, String)> {
1652        self.resident.get_refs_to(id)
1653    }
1654
1655    /// Expand cross-references to get related entities
1656    pub fn expand_refs(
1657        &self,
1658        id: EntityId,
1659        depth: u32,
1660        ref_types: Option<&[RefType]>,
1661    ) -> Vec<(UnifiedEntity, u32, RefType)> {
1662        let mut results = Vec::new();
1663        let mut visited = std::collections::HashSet::new();
1664        visited.insert(id);
1665
1666        self.expand_refs_recursive(id, depth, ref_types, &mut visited, &mut results, 1);
1667
1668        results
1669    }
1670
1671    fn expand_refs_recursive(
1672        &self,
1673        id: EntityId,
1674        max_depth: u32,
1675        ref_types: Option<&[RefType]>,
1676        visited: &mut std::collections::HashSet<EntityId>,
1677        results: &mut Vec<(UnifiedEntity, u32, RefType)>,
1678        current_depth: u32,
1679    ) {
1680        if current_depth > max_depth {
1681            return;
1682        }
1683
1684        for (target_id, ref_type, target_collection) in self.get_refs_from(id) {
1685            if visited.contains(&target_id) {
1686                continue;
1687            }
1688
1689            if let Some(types) = ref_types {
1690                if !types.contains(&ref_type) {
1691                    continue;
1692                }
1693            }
1694
1695            visited.insert(target_id);
1696
1697            if let Some(entity) = self.get(&target_collection, target_id) {
1698                results.push((entity, current_depth, ref_type));
1699
1700                // Recurse
1701                self.expand_refs_recursive(
1702                    target_id,
1703                    max_depth,
1704                    ref_types,
1705                    visited,
1706                    results,
1707                    current_depth + 1,
1708                );
1709            }
1710        }
1711    }
1712
1713    /// Index cross-references from an entity
1714    pub(crate) fn index_cross_refs(
1715        &self,
1716        entity: &UnifiedEntity,
1717        collection: &str,
1718    ) -> Result<(), StoreError> {
1719        self.resident.index_cross_refs(entity, collection)
1720    }
1721
1722    /// Remove cross-references for an entity
1723    pub(crate) fn unindex_cross_refs(&self, id: EntityId) -> Result<(), StoreError> {
1724        self.resident.unindex_cross_refs(id)
1725    }
1726
1727    pub(crate) fn unindex_cross_refs_batch(&self, ids: &[EntityId]) -> Result<(), StoreError> {
1728        self.resident.unindex_cross_refs_batch(ids)
1729    }
1730
1731    /// Test/observability hook: number of times `unindex_cross_refs_batch`
1732    /// took the read-only fast path. Used to pin the early-exit in tests
1733    /// and as a cheap signal in delete-heavy benchmarks.
1734    pub fn unindex_cross_refs_fast_path_hits(&self) -> u64 {
1735        self.resident.unindex_cross_refs_fast_path_hits()
1736    }
1737
1738    /// Query across all collections with a filter
1739    pub fn query_all<F>(&self, filter: F) -> Vec<(String, UnifiedEntity)>
1740    where
1741        F: Fn(&UnifiedEntity) -> bool + Clone + Send + Sync,
1742    {
1743        self.resident.query_all(filter)
1744    }
1745
1746    /// Scan every collection under an explicit MVCC snapshot.
1747    pub fn scan<F>(
1748        &self,
1749        snapshot: Option<&crate::runtime::impl_core::SnapshotContext>,
1750        filter: F,
1751    ) -> Vec<(String, UnifiedEntity)>
1752    where
1753        F: Fn(&UnifiedEntity) -> bool + Clone + Send + Sync,
1754    {
1755        self.resident.scan(snapshot, filter)
1756    }
1757
1758    /// Filter by metadata across all collections
1759    pub fn filter_metadata_all(
1760        &self,
1761        filters: &[(String, MetadataFilter)],
1762    ) -> Vec<(String, EntityId)> {
1763        self.resident.filter_metadata_all(filters)
1764    }
1765
1766    /// Get statistics
1767    pub fn stats(&self) -> StoreStats {
1768        // Issue #815 — snapshot the manager handles, drop the `collections`
1769        // read lock, then gather per-collection stats off the map lock. The
1770        // readiness probe (via `health()`) walks `stats()`, so holding the
1771        // store lock across a full-store stats scan was part of the same
1772        // wedge that froze `/catalog` during a large replica re-apply.
1773        let pairs: Vec<(String, Arc<SegmentManager>)> = {
1774            let collections = self.resident.collections.read();
1775            collections
1776                .iter()
1777                .map(|(name, mgr)| (name.clone(), Arc::clone(mgr)))
1778                .collect()
1779        };
1780
1781        let mut stats = StoreStats {
1782            collection_count: pairs.len(),
1783            ..Default::default()
1784        };
1785
1786        for (name, manager) in &pairs {
1787            let manager_stats = manager.stats();
1788            stats.total_entities += manager_stats.total_entities;
1789            stats.total_memory_bytes += manager_stats.total_memory_bytes;
1790            stats.total_memory_bytes += manager.checkpoint_tracking_bytes();
1791            stats.collections.insert(name.clone(), manager_stats);
1792        }
1793
1794        stats.total_memory_bytes += self.checkpoint_durable_rows.read().memory_bytes();
1795        stats.total_memory_bytes += self
1796            .checkpoint_versions
1797            .read()
1798            .values()
1799            .filter_map(|version| version.deferred_rows.as_ref())
1800            .map(|rows| rows.capacity() * std::mem::size_of::<EntityId>())
1801            .sum::<usize>();
1802        stats
1803    }
1804
1805    /// Run maintenance on all collections
1806    pub fn run_maintenance(&self) -> Result<(), StoreError> {
1807        let collections = self.resident.collections.read();
1808        for manager in collections.values() {
1809            manager.run_maintenance()?;
1810        }
1811        Ok(())
1812    }
1813
1814    /// Bytes pressure consolidation could plausibly return across collections.
1815    pub fn reclaimable_segment_bytes(&self) -> u64 {
1816        let collections = self.resident.collections.read();
1817        collections
1818            .values()
1819            .map(|manager| manager.reclaimable_bytes())
1820            .fold(0, u64::saturating_add)
1821    }
1822
1823    /// Advance pressure-triggered consolidation by one paced tick per
1824    /// collection. Returns true if any collection started or advanced a run.
1825    pub fn pressure_consolidation_tick(&self) -> bool {
1826        let managers: Vec<_> = self.resident.collections.read().values().cloned().collect();
1827        // A plain loop, not `any()`: every collection must receive its tick —
1828        // short-circuiting on the first advanced run would starve the rest.
1829        let mut advanced = false;
1830        for manager in &managers {
1831            advanced |= manager.pressure_consolidation_tick();
1832        }
1833        advanced
1834    }
1835}
1836
1837/// Flatten a JSON value into dot-notation key-value pairs for red.config.
1838fn flatten_config_json(
1839    prefix: &str,
1840    value: &crate::serde_json::Value,
1841    out: &mut Vec<(String, reddb_types::Value)>,
1842) {
1843    use reddb_types::Value;
1844    match value {
1845        crate::serde_json::Value::Object(map) => {
1846            for (k, v) in map {
1847                let key = if prefix.is_empty() {
1848                    k.clone()
1849                } else {
1850                    format!("{prefix}.{k}")
1851                };
1852                flatten_config_json(&key, v, out);
1853            }
1854        }
1855        crate::serde_json::Value::String(s) => {
1856            out.push((prefix.to_string(), Value::text(s.clone())));
1857        }
1858        crate::serde_json::Value::Integer(n) => {
1859            out.push((prefix.to_string(), Value::Integer(*n)));
1860        }
1861        crate::serde_json::Value::Number(n) => {
1862            if n.fract().abs() < f64::EPSILON {
1863                out.push((prefix.to_string(), Value::UnsignedInteger(*n as u64)));
1864            } else {
1865                out.push((prefix.to_string(), Value::Float(*n)));
1866            }
1867        }
1868        crate::serde_json::Value::Decimal(n) => {
1869            out.push((prefix.to_string(), Value::DecimalText(n.clone())));
1870        }
1871        crate::serde_json::Value::Bool(b) => {
1872            out.push((prefix.to_string(), Value::Boolean(*b)));
1873        }
1874        crate::serde_json::Value::Null => {
1875            out.push((prefix.to_string(), Value::Null));
1876        }
1877        crate::serde_json::Value::Array(_) => {
1878            let json_str = crate::serde_json::to_string(value).unwrap_or_default();
1879            out.push((prefix.to_string(), Value::text(json_str)));
1880        }
1881    }
1882}