Skip to main content

reddb_server/storage/unified/store/
impl_entities.rs

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