Skip to main content

reddb_server/runtime/
impl_catalog_accessors.rs

1//! Runtime catalog / stats / maintenance accessors.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 8/10, issue #1629).
4//! Houses connection `acquire`, `checkpoint`, the remote-write assertion,
5//! `run_maintenance`, `scan_collection`, and the catalog /
6//! attention-summary / `stats` readers.
7use super::*;
8
9fn runtime_pool_lock(runtime: &RedDBRuntime) -> std::sync::MutexGuard<'_, PoolState> {
10    runtime
11        .inner
12        .pool
13        .lock()
14        .unwrap_or_else(|poisoned| poisoned.into_inner())
15}
16
17impl RedDBRuntime {
18    pub fn acquire(&self) -> RedDBResult<RuntimeConnection> {
19        self.acquire_connection(true)
20    }
21
22    // Transport admission already bounds wire sessions. Share connection IDs
23    // and rollback cleanup without imposing the embedded pool's separate cap.
24    pub(crate) fn acquire_wire_connection(&self) -> RedDBResult<RuntimeConnection> {
25        self.acquire_connection(false)
26    }
27
28    fn acquire_connection(&self, uses_pool_slot: bool) -> RedDBResult<RuntimeConnection> {
29        let mut pool = self
30            .inner
31            .pool
32            .lock()
33            .map_err(|e| RedDBError::Internal(format!("connection pool lock poisoned: {e}")))?;
34        if uses_pool_slot && pool.pooled_active >= self.inner.pool_config.max_connections {
35            return Err(RedDBError::Internal(
36                "connection pool exhausted".to_string(),
37            ));
38        }
39
40        let id = if let Some(id) = pool.idle.pop() {
41            id
42        } else {
43            let id = pool.next_id;
44            pool.next_id += 1;
45            id
46        };
47        pool.active += 1;
48        if uses_pool_slot {
49            pool.pooled_active += 1;
50        }
51        pool.total_checkouts += 1;
52        drop(pool);
53
54        // Issue #1245 — record the connection acquisition after releasing
55        // the pool lock so the lock hold time is unchanged.
56        self.inner.node_load_telemetry.record_connect();
57
58        Ok(RuntimeConnection {
59            background_tasks: self.background_tasks.clone(),
60            id,
61            uses_pool_slot,
62            inner: Arc::clone(&self.inner),
63        })
64    }
65
66    pub fn checkpoint(&self) -> RedDBResult<()> {
67        // Local fsync always allowed — losing the lease shouldn't
68        // prevent us from durably persisting what's already in memory.
69        // The remote upload is the side-effect that risks clobbering a
70        // peer's state, so it's behind the lease gate.
71        self.checkpoint_local()?;
72        if let Err(err) = self.assert_remote_write_allowed("checkpoint") {
73            tracing::warn!(
74                target: "reddb::serverless::lease",
75                error = %err,
76                "checkpoint: skipping remote upload — lease not held"
77            );
78            return Ok(());
79        }
80        self.publish_remote_snapshot()
81    }
82
83    pub(super) fn checkpoint_local(&self) -> RedDBResult<()> {
84        let started = std::time::Instant::now();
85        self.seal_hypertable_chunks_for_checkpoint(
86            self.inner.checkpoint_columnar_emission_budget_chunks,
87        )?;
88        let store = self.inner.db.store();
89        let publication = store.vault_publication_lock.lock();
90        // Collections created since startup need the compact visibility
91        // inventory before their first resident-to-disk publication too.
92        self.prepare_paged_counts()?;
93        self.persist_probabilistic_snapshots()?;
94        self.inner
95            .db
96            .flush_local_after_runtime_snapshots()
97            .map_err(|err| {
98                // Issue #205 — local flush failure is a CheckpointFailed
99                // operator-grade event. The local-flush path also covers
100                // the WAL fsync we depend on, so a failure here doubles as
101                // the WalFsyncFailed signal for the runtime entry point.
102                let msg = err.to_string();
103                crate::telemetry::operator_event::OperatorEvent::CheckpointFailed {
104                    lsn: 0,
105                    error: msg.clone(),
106                }
107                .emit_global();
108                crate::telemetry::operator_event::OperatorEvent::WalFsyncFailed {
109                    path: "<flush_local_only>".to_string(),
110                    error: msg.clone(),
111                }
112                .emit_global();
113                RedDBError::Engine(msg)
114            })?;
115        // The checkpoint now contains every mutation fenced above. Rebase the
116        // ordered live overlay while publication is still exclusive so a
117        // concurrent writer cannot be cleared after its WAL change.
118        self.index_store_ref().clear_paged_btree_overlays();
119        self.inner.probabilistic.replayed_names.lock().clear();
120        drop(publication);
121        let checkpoint_lsn = self.replication_commit_lsn();
122        self.inner
123            .checkpoint_projection_stats
124            .record_checkpoint(checkpoint_lsn, started.elapsed().as_millis() as u64);
125        Ok(())
126    }
127
128    /// Guard remote mutations on replication role and writer lease.
129    /// With a backend configured, replicas cannot publish and other nodes
130    /// require a `NotRequired` or `Held` lease state. Local checkpoints can
131    /// still finish when this guard returns `RedDBError::ReadOnly`.
132    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
133        if self.inner.db.remote_backend.is_none() {
134            return Ok(());
135        }
136        if matches!(
137            self.inner.write_gate.role(),
138            crate::replication::ReplicationRole::Replica { .. }
139        ) {
140            return Err(RedDBError::ReadOnly(format!(
141                "replica cannot publish remote data — {action} blocked"
142            )));
143        }
144        match self.inner.write_gate.lease_state() {
145            crate::runtime::write_gate::LeaseGateState::NotHeld => {
146                self.inner.audit_log.record(
147                    action,
148                    "system",
149                    "remote_backend",
150                    "err: writer lease not held",
151                    crate::json::Value::Null,
152                );
153                Err(RedDBError::ReadOnly(format!(
154                    "writer lease not held — {action} blocked (serverless fence)"
155                )))
156            }
157            _ => Ok(()),
158        }
159    }
160
161    pub(super) fn publish_remote_snapshot(&self) -> RedDBResult<()> {
162        self.assert_remote_write_allowed("snapshot publication")?;
163        if let Some(lifecycle) = self.lease_lifecycle() {
164            let store = self.inner.db.store();
165            // Other snapshot entrypoints already hold this reentrant lock.
166            // Keep a consistent publication -> lease transition lock order.
167            let _publication = store.vault_publication_lock.lock();
168            if lifecycle.publish_snapshot(|| self.capture_serverless_snapshot())? {
169                return Ok(());
170            }
171        }
172        if self.inner.db.is_paged()
173            && self.inner.db.remote_backend.is_some()
174            && self.inner.db.remote_key.is_some()
175        {
176            // Native writers hold a lifetime file lock. Copy through the
177            // pager's own handle before handing the checkpoint to a backend.
178            let snapshot = self.capture_serverless_snapshot()?;
179            self.assert_remote_write_allowed("checkpoint upload")?;
180            return self
181                .inner
182                .db
183                .upload_checkpoint_to_remote_backend(snapshot.path())
184                .map_err(|err| RedDBError::Engine(err.to_string()));
185        }
186        self.inner
187            .db
188            .upload_to_remote_backend()
189            .map_err(|err| RedDBError::Engine(err.to_string()))
190    }
191
192    pub(crate) fn capture_serverless_snapshot(
193        &self,
194    ) -> RedDBResult<reddb_file::ServerlessWriterLeaseTempFile> {
195        use reddb_file::VfsFile;
196        use std::io::Write;
197        let store = self.inner.db.store();
198        let _publication = store.vault_publication_lock.lock();
199        self.checkpoint_local()?;
200        let snapshot = reddb_file::ServerlessWriterLeaseTempFile::new("snapshot-payload");
201        if let Some(pager) = store.pager() {
202            // Use the pager's own handle: Windows excludes reads through any
203            // second handle while the native writer owns the file lock.
204            pager
205                .with_file(|source| -> std::io::Result<()> {
206                    let mut output = std::fs::File::create(snapshot.path())?;
207                    source.seek(std::io::SeekFrom::Start(0))?;
208                    let mut buffer = [0u8; 65536];
209                    loop {
210                        let count = source.read(&mut buffer)?;
211                        if count == 0 {
212                            break;
213                        }
214                        output.write_all(&buffer[..count])?;
215                    }
216                    output.sync_all()
217                })
218                .map_err(|err| RedDBError::Internal(err.to_string()))?
219                .map_err(|err| RedDBError::Internal(err.to_string()))?;
220        } else {
221            let path = self.inner.db.path().ok_or_else(|| {
222                RedDBError::InvalidOperation(
223                    "serverless snapshots require persistent local storage".into(),
224                )
225            })?;
226            std::fs::copy(path, snapshot.path())
227                .map_err(|err| RedDBError::Internal(err.to_string()))?;
228        }
229        Ok(snapshot)
230    }
231
232    pub fn run_maintenance(&self) -> RedDBResult<()> {
233        self.inner
234            .db
235            .run_maintenance()
236            .map_err(|err| RedDBError::Internal(err.to_string()))
237    }
238
239    pub fn scan_collection(
240        &self,
241        collection: &str,
242        cursor: Option<ScanCursor>,
243        limit: usize,
244    ) -> RedDBResult<ScanPage> {
245        use reddb_rql::ast::PolicyAction;
246
247        self.check_collection_privilege(PolicyAction::Select, collection)?;
248        let store = self.inner.db.store();
249        let manager = store
250            .get_collection(collection)
251            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
252
253        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
254        let limit = limit.max(1);
255        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
256            .unwrap_or(usize::MAX)
257            .clamp(1, 16 * 1024 * 1024);
258        let snapshot = crate::runtime::execution_context::capture_current_snapshot()
259            .unwrap_or_else(|| {
260                let context = self
261                    .inner
262                    .transaction_state
263                    .context(crate::runtime::execution_context::current_connection_id());
264                let serializable_reader = context.as_ref().and_then(|context| {
265                    (context.isolation == crate::storage::transaction::IsolationLevel::Serializable)
266                        .then_some(context.xid)
267                });
268                crate::runtime::execution_context::SnapshotContext {
269                    snapshot: self.current_snapshot(),
270                    manager: self.snapshot_manager(),
271                    own_xids: self.own_transaction_xids(),
272                    requires_index_fallback: true,
273                    serializable_reader,
274                }
275            });
276        let apply_rls = crate::runtime::execution_context::current_auth_identity().is_some()
277            && crate::runtime::rls_injection::rls_is_enabled(self, collection);
278        let (total, items) = manager.try_page_in_id_order_where_at_snapshot(
279            Some(&snapshot),
280            offset,
281            limit,
282            read_bytes,
283            |entity| {
284                !apply_rls
285                    || crate::runtime::rls_injection::entity_passes_rls_action(
286                        self,
287                        collection,
288                        PolicyAction::Select,
289                        entity,
290                    )
291            },
292        )?;
293        let end = total.min(offset.saturating_add(limit));
294        let next = (end < total).then_some(ScanCursor { offset: end });
295
296        Ok(ScanPage {
297            collection: collection.to_string(),
298            items,
299            next,
300            total,
301        })
302    }
303
304    pub fn get_collection_entity(
305        &self,
306        collection: &str,
307        id: crate::storage::EntityId,
308    ) -> RedDBResult<Option<crate::storage::UnifiedEntity>> {
309        use reddb_rql::ast::PolicyAction;
310
311        self.check_collection_privilege(PolicyAction::Select, collection)?;
312        let entity = self.inner.db.store().get(collection, id);
313        Ok(entity.filter(|entity| {
314            crate::runtime::rls_injection::entity_passes_rls_action(
315                self,
316                collection,
317                PolicyAction::Select,
318                entity,
319            )
320        }))
321    }
322
323    pub fn catalog(&self) -> CatalogModelSnapshot {
324        self.inner.db.catalog_model_snapshot()
325    }
326
327    /// Fallible catalog for disk-resident databases. The decode workspace shares
328    /// query admission with concurrent reads and is released on every error.
329    pub fn try_catalog(&self) -> RedDBResult<CatalogModelSnapshot> {
330        if self
331            .inner
332            .db
333            .store()
334            .config()
335            .resident_memory_bytes_max
336            .is_none()
337        {
338            return Ok(self.catalog());
339        }
340        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
341            .unwrap_or(usize::MAX)
342            .clamp(1, 16 * 1024 * 1024);
343        let _scratch = self
344            .try_reserve_query_buffer(read_bytes * 2)
345            .ok_or_else(|| {
346                self.memory_accounting().record_admission_denied();
347                RedDBError::MaterializationLimitExceeded {
348                    executor: "catalog",
349                    limit: usize::try_from(self.memory_budget().resolved_bytes)
350                        .unwrap_or(usize::MAX),
351                    current: usize::try_from(self.memory_accounting().total_used_bytes())
352                        .unwrap_or(usize::MAX)
353                        .saturating_add(1),
354                }
355            })?;
356        Ok(self.inner.db.try_catalog_model_snapshot(read_bytes)?)
357    }
358
359    /// Fallible catalog inventory scoped to known collection names. This keeps
360    /// filtered `red.collections` queries from decoding every unrelated
361    /// collection before their predicate is applied.
362    pub(crate) fn try_catalog_for_collections(
363        &self,
364        collection_names: &std::collections::HashSet<String>,
365        observe_legacy_payloads: bool,
366    ) -> RedDBResult<CatalogModelSnapshot> {
367        if self
368            .inner
369            .db
370            .store()
371            .config()
372            .resident_memory_bytes_max
373            .is_none()
374        {
375            return Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
376                collection_names,
377                observe_legacy_payloads,
378                usize::MAX,
379            )?);
380        }
381        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
382            .unwrap_or(usize::MAX)
383            .clamp(1, 16 * 1024 * 1024);
384        let _scratch = self
385            .try_reserve_query_buffer(read_bytes * 2)
386            .ok_or_else(|| {
387                self.memory_accounting().record_admission_denied();
388                RedDBError::MaterializationLimitExceeded {
389                    executor: "catalog",
390                    limit: usize::try_from(self.memory_budget().resolved_bytes)
391                        .unwrap_or(usize::MAX),
392                    current: usize::try_from(self.memory_accounting().total_used_bytes())
393                        .unwrap_or(usize::MAX)
394                        .saturating_add(1),
395                }
396            })?;
397        Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
398            collection_names,
399            observe_legacy_payloads,
400            read_bytes,
401        )?)
402    }
403
404    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
405        self.inner.db.catalog_consistency_report()
406    }
407
408    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
409        crate::catalog::attention_summary(&self.catalog())
410    }
411
412    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
413        crate::catalog::collection_attention(&self.catalog())
414    }
415
416    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
417        crate::catalog::index_attention(&self.catalog())
418    }
419
420    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
421        crate::catalog::graph_projection_attention(&self.catalog())
422    }
423
424    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
425        crate::catalog::analytics_job_attention(&self.catalog())
426    }
427
428    pub fn stats(&self) -> RuntimeStats {
429        let pool = runtime_pool_lock(self);
430        RuntimeStats {
431            active_connections: pool.active,
432            idle_connections: pool.idle.len(),
433            total_checkouts: pool.total_checkouts,
434            paged_mode: self.inner.db.is_paged(),
435            started_at_unix_ms: self.inner.started_at_unix_ms,
436            store: self.inner.db.stats(),
437            system: SystemInfo::collect(),
438            result_blob_cache: self.inner.result_blob_cache.stats(),
439            kv: self.inner.kv_stats.snapshot(),
440            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
441        }
442    }
443}
444
445#[cfg(test)]
446mod security_boundary_tests {
447    use super::*;
448    use crate::application::entity::{DeleteEntityInput, PatchEntityInput};
449    use crate::application::ports::RuntimeEntityPort;
450    use crate::auth::Role;
451    use crate::runtime::execution_context::{with_snapshot_bundle, SnapshotBundle};
452    use crate::storage::{EntityData, UnifiedEntity};
453    use reddb_types::Value;
454
455    fn text_field<'a>(entity: &'a UnifiedEntity, name: &str) -> Option<&'a str> {
456        let EntityData::Row(row) = &entity.data else {
457            return None;
458        };
459        let value = row
460            .named
461            .as_ref()
462            .and_then(|named| named.get(name))
463            .or_else(|| {
464                row.schema.as_ref().and_then(|schema| {
465                    schema
466                        .iter()
467                        .position(|column| column == name)
468                        .and_then(|index| row.columns.get(index))
469                })
470            })?;
471        match value {
472            Value::Text(value) => Some(value.as_ref()),
473            _ => None,
474        }
475    }
476
477    #[test]
478    fn typed_entity_boundary_filters_before_pagination_and_hides_foreign_ids() {
479        let runtime = RedDBRuntime::in_memory().expect("runtime");
480        runtime
481            .execute_query(
482                "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) TENANT BY (tenant_id)",
483            )
484            .expect("tenant table");
485        runtime
486            .execute_query(
487                "INSERT INTO docs (id, tenant_id, body) VALUES \
488                 (1, 'acme', 'first'), (2, 'globex', 'hidden'), (3, 'acme', 'second')",
489            )
490            .expect("seed platform rows");
491
492        let unfiltered = runtime
493            .scan_collection("docs", None, 10)
494            .expect("platform scan");
495        let foreign_id = unfiltered
496            .items
497            .iter()
498            .find(|entity| text_field(entity, "tenant_id") == Some("globex"))
499            .map(|entity| entity.id)
500            .expect("foreign row");
501
502        let bundle = SnapshotBundle {
503            snapshot: None,
504            auth: Some(("alice".to_string(), Role::Write)),
505            auth_principal: Some(crate::auth::UserId::scoped("acme", "alice")),
506            tenant: Some("acme".to_string()),
507        };
508        with_snapshot_bundle(&bundle, || {
509            let first = runtime
510                .scan_collection("docs", None, 1)
511                .expect("first visible page");
512            assert_eq!(first.total, 2);
513            assert_eq!(first.items.len(), 1);
514            assert_eq!(text_field(&first.items[0], "tenant_id"), Some("acme"));
515            assert_eq!(first.next, Some(ScanCursor { offset: 1 }));
516
517            let second = runtime
518                .scan_collection("docs", first.next, 1)
519                .expect("second visible page");
520            assert_eq!(second.total, 2);
521            assert_eq!(second.items.len(), 1);
522            assert_eq!(text_field(&second.items[0], "tenant_id"), Some("acme"));
523            assert_eq!(second.next, None);
524
525            assert!(runtime
526                .get_collection_entity("docs", foreign_id)
527                .expect("typed get")
528                .is_none());
529            assert!(matches!(
530                runtime.patch_entity(PatchEntityInput {
531                    collection: "docs".to_string(),
532                    id: foreign_id,
533                    payload: crate::json::Value::Object(Default::default()),
534                    operations: Vec::new(),
535                }),
536                Err(crate::RedDBError::NotFound(_))
537            ));
538            let deleted = runtime
539                .delete_entity(DeleteEntityInput {
540                    collection: "docs".to_string(),
541                    id: foreign_id,
542                })
543                .expect("typed delete");
544            assert!(!deleted.deleted);
545        });
546
547        assert!(runtime.db().store().get("docs", foreign_id).is_some());
548    }
549
550    #[test]
551    fn scan_collection_pages_the_mvcc_view_across_rollback() {
552        let runtime = RedDBRuntime::in_memory().expect("runtime");
553        runtime
554            .execute_query("CREATE TABLE docs (id INT, body TEXT)")
555            .expect("table");
556        runtime
557            .execute_query("INSERT INTO docs (id, body) VALUES (1, 'kept')")
558            .expect("seed row");
559
560        runtime.execute_query("BEGIN").expect("begin");
561        runtime
562            .execute_query("DELETE FROM docs WHERE id = 1")
563            .expect("delete in transaction");
564        let deleted = runtime
565            .scan_collection("docs", None, 1)
566            .expect("scan own transaction");
567        assert_eq!(deleted.total, 0);
568        assert!(deleted.items.is_empty());
569
570        runtime.execute_query("ROLLBACK").expect("rollback");
571        let restored = runtime
572            .scan_collection("docs", None, 1)
573            .expect("scan after rollback");
574        assert_eq!(restored.total, 1);
575        assert_eq!(restored.items.len(), 1);
576        assert_eq!(text_field(&restored.items[0], "body"), Some("kept"));
577        assert_eq!(restored.next, None);
578    }
579
580    #[test]
581    fn typed_entity_boundary_uses_model_specific_rls_policies() {
582        let runtime = RedDBRuntime::in_memory().expect("runtime");
583        for query in [
584            "CREATE GRAPH links",
585            "INSERT INTO links NODE (label,node_type,visible) VALUES ('shown','Item',true)",
586            "INSERT INTO links NODE (label,node_type,visible) VALUES ('hidden','Item',false)",
587            "CREATE POLICY visible_nodes ON NODES OF links FOR SELECT USING (properties.visible=true)",
588            "ALTER TABLE links ENABLE ROW LEVEL SECURITY",
589            "CREATE DOCUMENT papers",
590            "INSERT INTO papers DOCUMENT VALUES ({name:'shown',visible:true})",
591            "INSERT INTO papers DOCUMENT VALUES ({name:'hidden',visible:false})",
592            "CREATE POLICY visible_documents ON DOCUMENTS OF papers FOR SELECT USING (visible=true)",
593            "ALTER TABLE papers ENABLE ROW LEVEL SECURITY",
594        ] {
595            runtime.execute_query(query).expect(query);
596        }
597
598        let bundle = SnapshotBundle {
599            snapshot: None,
600            auth: Some(("alice".to_string(), Role::Read)),
601            auth_principal: Some(crate::auth::UserId::platform("alice")),
602            tenant: None,
603        };
604        with_snapshot_bundle(&bundle, || {
605            let nodes = runtime
606                .scan_collection("links", None, 10)
607                .expect("typed graph scan");
608            assert_eq!(nodes.total, 1);
609            assert_eq!(nodes.items.len(), 1);
610
611            let documents = runtime
612                .scan_collection("papers", None, 10)
613                .expect("typed document scan");
614            assert_eq!(documents.total, 1);
615            assert_eq!(documents.items.len(), 1);
616        });
617    }
618}