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