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