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