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