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        observe_legacy_payloads: bool,
263    ) -> RedDBResult<CatalogModelSnapshot> {
264        if self
265            .inner
266            .db
267            .store()
268            .config()
269            .resident_memory_bytes_max
270            .is_none()
271        {
272            return Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
273                collection_names,
274                observe_legacy_payloads,
275                usize::MAX,
276            )?);
277        }
278        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
279            .unwrap_or(usize::MAX)
280            .clamp(1, 16 * 1024 * 1024);
281        let _scratch = self
282            .try_reserve_query_buffer(read_bytes * 2)
283            .ok_or_else(|| {
284                self.memory_accounting().record_admission_denied();
285                RedDBError::MaterializationLimitExceeded {
286                    executor: "catalog",
287                    limit: usize::try_from(self.memory_budget().resolved_bytes)
288                        .unwrap_or(usize::MAX),
289                    current: usize::try_from(self.memory_accounting().total_used_bytes())
290                        .unwrap_or(usize::MAX)
291                        .saturating_add(1),
292                }
293            })?;
294        Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
295            collection_names,
296            observe_legacy_payloads,
297            read_bytes,
298        )?)
299    }
300
301    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
302        self.inner.db.catalog_consistency_report()
303    }
304
305    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
306        crate::catalog::attention_summary(&self.catalog())
307    }
308
309    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
310        crate::catalog::collection_attention(&self.catalog())
311    }
312
313    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
314        crate::catalog::index_attention(&self.catalog())
315    }
316
317    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
318        crate::catalog::graph_projection_attention(&self.catalog())
319    }
320
321    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
322        crate::catalog::analytics_job_attention(&self.catalog())
323    }
324
325    pub fn stats(&self) -> RuntimeStats {
326        let pool = runtime_pool_lock(self);
327        RuntimeStats {
328            active_connections: pool.active,
329            idle_connections: pool.idle.len(),
330            total_checkouts: pool.total_checkouts,
331            paged_mode: self.inner.db.is_paged(),
332            started_at_unix_ms: self.inner.started_at_unix_ms,
333            store: self.inner.db.stats(),
334            system: SystemInfo::collect(),
335            result_blob_cache: self.inner.result_blob_cache.stats(),
336            kv: self.inner.kv_stats.snapshot(),
337            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
338        }
339    }
340}
341
342#[cfg(test)]
343mod security_boundary_tests {
344    use super::*;
345    use crate::application::entity::{DeleteEntityInput, PatchEntityInput};
346    use crate::application::ports::RuntimeEntityPort;
347    use crate::auth::Role;
348    use crate::runtime::execution_context::{with_snapshot_bundle, SnapshotBundle};
349    use crate::storage::{EntityData, UnifiedEntity};
350    use reddb_types::Value;
351
352    fn text_field<'a>(entity: &'a UnifiedEntity, name: &str) -> Option<&'a str> {
353        let EntityData::Row(row) = &entity.data else {
354            return None;
355        };
356        let value = row
357            .named
358            .as_ref()
359            .and_then(|named| named.get(name))
360            .or_else(|| {
361                row.schema.as_ref().and_then(|schema| {
362                    schema
363                        .iter()
364                        .position(|column| column == name)
365                        .and_then(|index| row.columns.get(index))
366                })
367            })?;
368        match value {
369            Value::Text(value) => Some(value.as_ref()),
370            _ => None,
371        }
372    }
373
374    #[test]
375    fn typed_entity_boundary_filters_before_pagination_and_hides_foreign_ids() {
376        let runtime = RedDBRuntime::in_memory().expect("runtime");
377        runtime
378            .execute_query(
379                "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) TENANT BY (tenant_id)",
380            )
381            .expect("tenant table");
382        runtime
383            .execute_query(
384                "INSERT INTO docs (id, tenant_id, body) VALUES \
385                 (1, 'acme', 'first'), (2, 'globex', 'hidden'), (3, 'acme', 'second')",
386            )
387            .expect("seed platform rows");
388
389        let unfiltered = runtime
390            .scan_collection("docs", None, 10)
391            .expect("platform scan");
392        let foreign_id = unfiltered
393            .items
394            .iter()
395            .find(|entity| text_field(entity, "tenant_id") == Some("globex"))
396            .map(|entity| entity.id)
397            .expect("foreign row");
398
399        let bundle = SnapshotBundle {
400            snapshot: None,
401            auth: Some(("alice".to_string(), Role::Write)),
402            auth_principal: Some(crate::auth::UserId::scoped("acme", "alice")),
403            tenant: Some("acme".to_string()),
404        };
405        with_snapshot_bundle(&bundle, || {
406            let first = runtime
407                .scan_collection("docs", None, 1)
408                .expect("first visible page");
409            assert_eq!(first.total, 2);
410            assert_eq!(first.items.len(), 1);
411            assert_eq!(text_field(&first.items[0], "tenant_id"), Some("acme"));
412            assert_eq!(first.next, Some(ScanCursor { offset: 1 }));
413
414            let second = runtime
415                .scan_collection("docs", first.next, 1)
416                .expect("second visible page");
417            assert_eq!(second.total, 2);
418            assert_eq!(second.items.len(), 1);
419            assert_eq!(text_field(&second.items[0], "tenant_id"), Some("acme"));
420            assert_eq!(second.next, None);
421
422            assert!(runtime
423                .get_collection_entity("docs", foreign_id)
424                .expect("typed get")
425                .is_none());
426            assert!(matches!(
427                runtime.patch_entity(PatchEntityInput {
428                    collection: "docs".to_string(),
429                    id: foreign_id,
430                    payload: crate::json::Value::Object(Default::default()),
431                    operations: Vec::new(),
432                }),
433                Err(crate::RedDBError::NotFound(_))
434            ));
435            let deleted = runtime
436                .delete_entity(DeleteEntityInput {
437                    collection: "docs".to_string(),
438                    id: foreign_id,
439                })
440                .expect("typed delete");
441            assert!(!deleted.deleted);
442        });
443
444        assert!(runtime.db().store().get("docs", foreign_id).is_some());
445    }
446
447    #[test]
448    fn typed_entity_boundary_uses_model_specific_rls_policies() {
449        let runtime = RedDBRuntime::in_memory().expect("runtime");
450        for query in [
451            "CREATE GRAPH links",
452            "INSERT INTO links NODE (label,node_type,visible) VALUES ('shown','Item',true)",
453            "INSERT INTO links NODE (label,node_type,visible) VALUES ('hidden','Item',false)",
454            "CREATE POLICY visible_nodes ON NODES OF links FOR SELECT USING (properties.visible=true)",
455            "ALTER TABLE links ENABLE ROW LEVEL SECURITY",
456            "CREATE DOCUMENT papers",
457            "INSERT INTO papers DOCUMENT VALUES ({name:'shown',visible:true})",
458            "INSERT INTO papers DOCUMENT VALUES ({name:'hidden',visible:false})",
459            "CREATE POLICY visible_documents ON DOCUMENTS OF papers FOR SELECT USING (visible=true)",
460            "ALTER TABLE papers ENABLE ROW LEVEL SECURITY",
461        ] {
462            runtime.execute_query(query).expect(query);
463        }
464
465        let bundle = SnapshotBundle {
466            snapshot: None,
467            auth: Some(("alice".to_string(), Role::Read)),
468            auth_principal: Some(crate::auth::UserId::platform("alice")),
469            tenant: None,
470        };
471        with_snapshot_bundle(&bundle, || {
472            let nodes = runtime
473                .scan_collection("links", None, 10)
474                .expect("typed graph scan");
475            assert_eq!(nodes.total, 1);
476            assert_eq!(nodes.items.len(), 1);
477
478            let documents = runtime
479                .scan_collection("papers", None, 10)
480                .expect("typed document scan");
481            assert_eq!(documents.total, 1);
482            assert_eq!(documents.items.len(), 1);
483        });
484    }
485}