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.inner
81            .db
82            .upload_to_remote_backend()
83            .map_err(|err| RedDBError::Engine(err.to_string()))
84    }
85
86    pub(super) fn checkpoint_local(&self) -> RedDBResult<()> {
87        let started = std::time::Instant::now();
88        self.seal_hypertable_chunks_for_checkpoint(
89            self.inner.checkpoint_columnar_emission_budget_chunks,
90        )?;
91        let store = self.inner.db.store();
92        let publication = store.vault_publication_lock.lock();
93        // Collections created since startup need the compact visibility
94        // inventory before their first resident-to-disk publication too.
95        self.prepare_paged_counts()?;
96        self.persist_probabilistic_snapshots()?;
97        self.inner.db.flush_local_only().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        drop(publication);
116        let checkpoint_lsn = self.cdc_current_lsn();
117        self.inner
118            .checkpoint_projection_stats
119            .record_checkpoint(checkpoint_lsn, started.elapsed().as_millis() as u64);
120        Ok(())
121    }
122
123    /// Guard remote-mutating operations on the writer lease.
124    /// Returns `Ok(())` when no remote backend is configured (the
125    /// lease is irrelevant) or the lease state is `NotRequired` /
126    /// `Held`. Returns `RedDBError::ReadOnly` when the lease is
127    /// `NotHeld`, with an audit-friendly action label so the caller
128    /// can record the rejection.
129    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
130        if self.inner.db.remote_backend.is_none() {
131            return Ok(());
132        }
133        match self.inner.write_gate.lease_state() {
134            crate::runtime::write_gate::LeaseGateState::NotHeld => {
135                self.inner.audit_log.record(
136                    action,
137                    "system",
138                    "remote_backend",
139                    "err: writer lease not held",
140                    crate::json::Value::Null,
141                );
142                Err(RedDBError::ReadOnly(format!(
143                    "writer lease not held — {action} blocked (serverless fence)"
144                )))
145            }
146            _ => Ok(()),
147        }
148    }
149
150    pub fn run_maintenance(&self) -> RedDBResult<()> {
151        self.inner
152            .db
153            .run_maintenance()
154            .map_err(|err| RedDBError::Internal(err.to_string()))
155    }
156
157    pub fn scan_collection(
158        &self,
159        collection: &str,
160        cursor: Option<ScanCursor>,
161        limit: usize,
162    ) -> RedDBResult<ScanPage> {
163        use reddb_rql::ast::PolicyAction;
164
165        self.check_collection_privilege(PolicyAction::Select, collection)?;
166        let store = self.inner.db.store();
167        let manager = store
168            .get_collection(collection)
169            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
170
171        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
172        let limit = limit.max(1);
173        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
174            .unwrap_or(usize::MAX)
175            .clamp(1, 16 * 1024 * 1024);
176        let (total, items) = if crate::runtime::execution_context::current_auth_identity().is_some()
177            && crate::runtime::rls_injection::rls_is_enabled(self, collection)
178        {
179            manager.try_page_in_id_order_where(offset, limit, read_bytes, |entity| {
180                crate::runtime::rls_injection::entity_passes_rls_action(
181                    self,
182                    collection,
183                    PolicyAction::Select,
184                    entity,
185                )
186            })?
187        } else {
188            manager.try_page_in_id_order(offset, limit, read_bytes)?
189        };
190        let end = total.min(offset.saturating_add(limit));
191        let next = (end < total).then_some(ScanCursor { offset: end });
192
193        Ok(ScanPage {
194            collection: collection.to_string(),
195            items,
196            next,
197            total,
198        })
199    }
200
201    pub fn get_collection_entity(
202        &self,
203        collection: &str,
204        id: crate::storage::EntityId,
205    ) -> RedDBResult<Option<crate::storage::UnifiedEntity>> {
206        use reddb_rql::ast::PolicyAction;
207
208        self.check_collection_privilege(PolicyAction::Select, collection)?;
209        let entity = self.inner.db.store().get(collection, id);
210        Ok(entity.filter(|entity| {
211            crate::runtime::rls_injection::entity_passes_rls_action(
212                self,
213                collection,
214                PolicyAction::Select,
215                entity,
216            )
217        }))
218    }
219
220    pub fn catalog(&self) -> CatalogModelSnapshot {
221        self.inner.db.catalog_model_snapshot()
222    }
223
224    /// Fallible catalog for disk-resident databases. The decode workspace shares
225    /// query admission with concurrent reads and is released on every error.
226    pub fn try_catalog(&self) -> RedDBResult<CatalogModelSnapshot> {
227        if self
228            .inner
229            .db
230            .store()
231            .config()
232            .resident_memory_bytes_max
233            .is_none()
234        {
235            return Ok(self.catalog());
236        }
237        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
238            .unwrap_or(usize::MAX)
239            .clamp(1, 16 * 1024 * 1024);
240        let _scratch = self
241            .try_reserve_query_buffer(read_bytes * 2)
242            .ok_or_else(|| {
243                self.memory_accounting().record_admission_denied();
244                RedDBError::MaterializationLimitExceeded {
245                    executor: "catalog",
246                    limit: usize::try_from(self.memory_budget().resolved_bytes)
247                        .unwrap_or(usize::MAX),
248                    current: usize::try_from(self.memory_accounting().total_used_bytes())
249                        .unwrap_or(usize::MAX)
250                        .saturating_add(1),
251                }
252            })?;
253        Ok(self.inner.db.try_catalog_model_snapshot(read_bytes)?)
254    }
255
256    /// Fallible catalog inventory scoped to known collection names. This keeps
257    /// filtered `red.collections` queries from decoding every unrelated
258    /// collection before their predicate is applied.
259    pub(crate) fn try_catalog_for_collections(
260        &self,
261        collection_names: &std::collections::HashSet<String>,
262    ) -> RedDBResult<CatalogModelSnapshot> {
263        if self
264            .inner
265            .db
266            .store()
267            .config()
268            .resident_memory_bytes_max
269            .is_none()
270        {
271            return Ok(self
272                .inner
273                .db
274                .try_catalog_model_snapshot_for_collections(collection_names, usize::MAX)?);
275        }
276        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
277            .unwrap_or(usize::MAX)
278            .clamp(1, 16 * 1024 * 1024);
279        let _scratch = self
280            .try_reserve_query_buffer(read_bytes * 2)
281            .ok_or_else(|| {
282                self.memory_accounting().record_admission_denied();
283                RedDBError::MaterializationLimitExceeded {
284                    executor: "catalog",
285                    limit: usize::try_from(self.memory_budget().resolved_bytes)
286                        .unwrap_or(usize::MAX),
287                    current: usize::try_from(self.memory_accounting().total_used_bytes())
288                        .unwrap_or(usize::MAX)
289                        .saturating_add(1),
290                }
291            })?;
292        Ok(self
293            .inner
294            .db
295            .try_catalog_model_snapshot_for_collections(collection_names, read_bytes)?)
296    }
297
298    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
299        self.inner.db.catalog_consistency_report()
300    }
301
302    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
303        crate::catalog::attention_summary(&self.catalog())
304    }
305
306    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
307        crate::catalog::collection_attention(&self.catalog())
308    }
309
310    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
311        crate::catalog::index_attention(&self.catalog())
312    }
313
314    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
315        crate::catalog::graph_projection_attention(&self.catalog())
316    }
317
318    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
319        crate::catalog::analytics_job_attention(&self.catalog())
320    }
321
322    pub fn stats(&self) -> RuntimeStats {
323        let pool = runtime_pool_lock(self);
324        RuntimeStats {
325            active_connections: pool.active,
326            idle_connections: pool.idle.len(),
327            total_checkouts: pool.total_checkouts,
328            paged_mode: self.inner.db.is_paged(),
329            started_at_unix_ms: self.inner.started_at_unix_ms,
330            store: self.inner.db.stats(),
331            system: SystemInfo::collect(),
332            result_blob_cache: self.inner.result_blob_cache.stats(),
333            kv: self.inner.kv_stats.snapshot(),
334            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
335        }
336    }
337}
338
339#[cfg(test)]
340mod security_boundary_tests {
341    use super::*;
342    use crate::application::entity::{DeleteEntityInput, PatchEntityInput};
343    use crate::application::ports::RuntimeEntityPort;
344    use crate::auth::Role;
345    use crate::runtime::execution_context::{with_snapshot_bundle, SnapshotBundle};
346    use crate::storage::{EntityData, UnifiedEntity};
347    use reddb_types::Value;
348
349    fn text_field<'a>(entity: &'a UnifiedEntity, name: &str) -> Option<&'a str> {
350        let EntityData::Row(row) = &entity.data else {
351            return None;
352        };
353        let value = row
354            .named
355            .as_ref()
356            .and_then(|named| named.get(name))
357            .or_else(|| {
358                row.schema.as_ref().and_then(|schema| {
359                    schema
360                        .iter()
361                        .position(|column| column == name)
362                        .and_then(|index| row.columns.get(index))
363                })
364            })?;
365        match value {
366            Value::Text(value) => Some(value.as_ref()),
367            _ => None,
368        }
369    }
370
371    #[test]
372    fn typed_entity_boundary_filters_before_pagination_and_hides_foreign_ids() {
373        let runtime = RedDBRuntime::in_memory().expect("runtime");
374        runtime
375            .execute_query(
376                "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) TENANT BY (tenant_id)",
377            )
378            .expect("tenant table");
379        runtime
380            .execute_query(
381                "INSERT INTO docs (id, tenant_id, body) VALUES \
382                 (1, 'acme', 'first'), (2, 'globex', 'hidden'), (3, 'acme', 'second')",
383            )
384            .expect("seed platform rows");
385
386        let unfiltered = runtime
387            .scan_collection("docs", None, 10)
388            .expect("platform scan");
389        let foreign_id = unfiltered
390            .items
391            .iter()
392            .find(|entity| text_field(entity, "tenant_id") == Some("globex"))
393            .map(|entity| entity.id)
394            .expect("foreign row");
395
396        let bundle = SnapshotBundle {
397            snapshot: None,
398            auth: Some(("alice".to_string(), Role::Write)),
399            auth_principal: Some(crate::auth::UserId::scoped("acme", "alice")),
400            tenant: Some("acme".to_string()),
401        };
402        with_snapshot_bundle(&bundle, || {
403            let first = runtime
404                .scan_collection("docs", None, 1)
405                .expect("first visible page");
406            assert_eq!(first.total, 2);
407            assert_eq!(first.items.len(), 1);
408            assert_eq!(text_field(&first.items[0], "tenant_id"), Some("acme"));
409            assert_eq!(first.next, Some(ScanCursor { offset: 1 }));
410
411            let second = runtime
412                .scan_collection("docs", first.next, 1)
413                .expect("second visible page");
414            assert_eq!(second.total, 2);
415            assert_eq!(second.items.len(), 1);
416            assert_eq!(text_field(&second.items[0], "tenant_id"), Some("acme"));
417            assert_eq!(second.next, None);
418
419            assert!(runtime
420                .get_collection_entity("docs", foreign_id)
421                .expect("typed get")
422                .is_none());
423            assert!(matches!(
424                runtime.patch_entity(PatchEntityInput {
425                    collection: "docs".to_string(),
426                    id: foreign_id,
427                    payload: crate::json::Value::Object(Default::default()),
428                    operations: Vec::new(),
429                }),
430                Err(crate::RedDBError::NotFound(_))
431            ));
432            let deleted = runtime
433                .delete_entity(DeleteEntityInput {
434                    collection: "docs".to_string(),
435                    id: foreign_id,
436                })
437                .expect("typed delete");
438            assert!(!deleted.deleted);
439        });
440
441        assert!(runtime.db().store().get("docs", foreign_id).is_some());
442    }
443
444    #[test]
445    fn typed_entity_boundary_uses_model_specific_rls_policies() {
446        let runtime = RedDBRuntime::in_memory().expect("runtime");
447        for query in [
448            "CREATE GRAPH links",
449            "INSERT INTO links NODE (label,node_type,visible) VALUES ('shown','Item',true)",
450            "INSERT INTO links NODE (label,node_type,visible) VALUES ('hidden','Item',false)",
451            "CREATE POLICY visible_nodes ON NODES OF links FOR SELECT USING (properties.visible=true)",
452            "ALTER TABLE links ENABLE ROW LEVEL SECURITY",
453            "CREATE DOCUMENT papers",
454            "INSERT INTO papers DOCUMENT VALUES ({name:'shown',visible:true})",
455            "INSERT INTO papers DOCUMENT VALUES ({name:'hidden',visible:false})",
456            "CREATE POLICY visible_documents ON DOCUMENTS OF papers FOR SELECT USING (visible=true)",
457            "ALTER TABLE papers ENABLE ROW LEVEL SECURITY",
458        ] {
459            runtime.execute_query(query).expect(query);
460        }
461
462        let bundle = SnapshotBundle {
463            snapshot: None,
464            auth: Some(("alice".to_string(), Role::Read)),
465            auth_principal: Some(crate::auth::UserId::platform("alice")),
466            tenant: None,
467        };
468        with_snapshot_bundle(&bundle, || {
469            let nodes = runtime
470                .scan_collection("links", None, 10)
471                .expect("typed graph scan");
472            assert_eq!(nodes.total, 1);
473            assert_eq!(nodes.items.len(), 1);
474
475            let documents = runtime
476                .scan_collection("papers", None, 10)
477                .expect("typed document scan");
478            assert_eq!(documents.total, 1);
479            assert_eq!(documents.items.len(), 1);
480        });
481    }
482}