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