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-mutating operations on the writer lease.
129    /// Returns `Ok(())` when no remote backend is configured (the
130    /// lease is irrelevant) or the lease state is `NotRequired` /
131    /// `Held`. Returns `RedDBError::ReadOnly` when the lease is
132    /// `NotHeld`, with an audit-friendly action label so the caller
133    /// can record the rejection.
134    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
135        if self.inner.db.remote_backend.is_none() {
136            return Ok(());
137        }
138        match self.inner.write_gate.lease_state() {
139            crate::runtime::write_gate::LeaseGateState::NotHeld => {
140                self.inner.audit_log.record(
141                    action,
142                    "system",
143                    "remote_backend",
144                    "err: writer lease not held",
145                    crate::json::Value::Null,
146                );
147                Err(RedDBError::ReadOnly(format!(
148                    "writer lease not held — {action} blocked (serverless fence)"
149                )))
150            }
151            _ => Ok(()),
152        }
153    }
154
155    pub(super) fn publish_remote_snapshot(&self) -> RedDBResult<()> {
156        if let Some(lifecycle) = self.lease_lifecycle() {
157            let store = self.inner.db.store();
158            // Other snapshot entrypoints already hold this reentrant lock.
159            // Keep a consistent publication -> lease transition lock order.
160            let _publication = store.vault_publication_lock.lock();
161            if lifecycle.publish_snapshot(|| self.capture_serverless_snapshot())? {
162                return Ok(());
163            }
164        }
165        self.inner
166            .db
167            .upload_to_remote_backend()
168            .map_err(|err| RedDBError::Engine(err.to_string()))
169    }
170
171    pub(super) fn capture_serverless_snapshot(
172        &self,
173    ) -> RedDBResult<reddb_file::ServerlessWriterLeaseTempFile> {
174        use reddb_file::VfsFile;
175        use std::io::Write;
176        let store = self.inner.db.store();
177        let _publication = store.vault_publication_lock.lock();
178        self.checkpoint_local()?;
179        let snapshot = reddb_file::ServerlessWriterLeaseTempFile::new("snapshot-payload");
180        if let Some(pager) = store.pager() {
181            // Use the pager's own handle: Windows excludes reads through any
182            // second handle while the native writer owns the file lock.
183            pager
184                .with_file(|source| -> std::io::Result<()> {
185                    let mut output = std::fs::File::create(snapshot.path())?;
186                    source.seek(std::io::SeekFrom::Start(0))?;
187                    let mut buffer = [0u8; 65536];
188                    loop {
189                        let count = source.read(&mut buffer)?;
190                        if count == 0 {
191                            break;
192                        }
193                        output.write_all(&buffer[..count])?;
194                    }
195                    output.sync_all()
196                })
197                .map_err(|err| RedDBError::Internal(err.to_string()))?
198                .map_err(|err| RedDBError::Internal(err.to_string()))?;
199        } else {
200            let path = self.inner.db.path().ok_or_else(|| {
201                RedDBError::InvalidOperation(
202                    "serverless snapshots require persistent local storage".into(),
203                )
204            })?;
205            std::fs::copy(path, snapshot.path())
206                .map_err(|err| RedDBError::Internal(err.to_string()))?;
207        }
208        Ok(snapshot)
209    }
210
211    pub fn run_maintenance(&self) -> RedDBResult<()> {
212        self.inner
213            .db
214            .run_maintenance()
215            .map_err(|err| RedDBError::Internal(err.to_string()))
216    }
217
218    pub fn scan_collection(
219        &self,
220        collection: &str,
221        cursor: Option<ScanCursor>,
222        limit: usize,
223    ) -> RedDBResult<ScanPage> {
224        use reddb_rql::ast::PolicyAction;
225
226        self.check_collection_privilege(PolicyAction::Select, collection)?;
227        let store = self.inner.db.store();
228        let manager = store
229            .get_collection(collection)
230            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
231
232        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
233        let limit = limit.max(1);
234        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
235            .unwrap_or(usize::MAX)
236            .clamp(1, 16 * 1024 * 1024);
237        let (total, items) = if crate::runtime::execution_context::current_auth_identity().is_some()
238            && crate::runtime::rls_injection::rls_is_enabled(self, collection)
239        {
240            manager.try_page_in_id_order_where(offset, limit, read_bytes, |entity| {
241                crate::runtime::rls_injection::entity_passes_rls_action(
242                    self,
243                    collection,
244                    PolicyAction::Select,
245                    entity,
246                )
247            })?
248        } else {
249            manager.try_page_in_id_order(offset, limit, read_bytes)?
250        };
251        let end = total.min(offset.saturating_add(limit));
252        let next = (end < total).then_some(ScanCursor { offset: end });
253
254        Ok(ScanPage {
255            collection: collection.to_string(),
256            items,
257            next,
258            total,
259        })
260    }
261
262    pub fn get_collection_entity(
263        &self,
264        collection: &str,
265        id: crate::storage::EntityId,
266    ) -> RedDBResult<Option<crate::storage::UnifiedEntity>> {
267        use reddb_rql::ast::PolicyAction;
268
269        self.check_collection_privilege(PolicyAction::Select, collection)?;
270        let entity = self.inner.db.store().get(collection, id);
271        Ok(entity.filter(|entity| {
272            crate::runtime::rls_injection::entity_passes_rls_action(
273                self,
274                collection,
275                PolicyAction::Select,
276                entity,
277            )
278        }))
279    }
280
281    pub fn catalog(&self) -> CatalogModelSnapshot {
282        self.inner.db.catalog_model_snapshot()
283    }
284
285    /// Fallible catalog for disk-resident databases. The decode workspace shares
286    /// query admission with concurrent reads and is released on every error.
287    pub fn try_catalog(&self) -> RedDBResult<CatalogModelSnapshot> {
288        if self
289            .inner
290            .db
291            .store()
292            .config()
293            .resident_memory_bytes_max
294            .is_none()
295        {
296            return Ok(self.catalog());
297        }
298        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
299            .unwrap_or(usize::MAX)
300            .clamp(1, 16 * 1024 * 1024);
301        let _scratch = self
302            .try_reserve_query_buffer(read_bytes * 2)
303            .ok_or_else(|| {
304                self.memory_accounting().record_admission_denied();
305                RedDBError::MaterializationLimitExceeded {
306                    executor: "catalog",
307                    limit: usize::try_from(self.memory_budget().resolved_bytes)
308                        .unwrap_or(usize::MAX),
309                    current: usize::try_from(self.memory_accounting().total_used_bytes())
310                        .unwrap_or(usize::MAX)
311                        .saturating_add(1),
312                }
313            })?;
314        Ok(self.inner.db.try_catalog_model_snapshot(read_bytes)?)
315    }
316
317    /// Fallible catalog inventory scoped to known collection names. This keeps
318    /// filtered `red.collections` queries from decoding every unrelated
319    /// collection before their predicate is applied.
320    pub(crate) fn try_catalog_for_collections(
321        &self,
322        collection_names: &std::collections::HashSet<String>,
323        observe_legacy_payloads: bool,
324    ) -> RedDBResult<CatalogModelSnapshot> {
325        if self
326            .inner
327            .db
328            .store()
329            .config()
330            .resident_memory_bytes_max
331            .is_none()
332        {
333            return Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
334                collection_names,
335                observe_legacy_payloads,
336                usize::MAX,
337            )?);
338        }
339        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
340            .unwrap_or(usize::MAX)
341            .clamp(1, 16 * 1024 * 1024);
342        let _scratch = self
343            .try_reserve_query_buffer(read_bytes * 2)
344            .ok_or_else(|| {
345                self.memory_accounting().record_admission_denied();
346                RedDBError::MaterializationLimitExceeded {
347                    executor: "catalog",
348                    limit: usize::try_from(self.memory_budget().resolved_bytes)
349                        .unwrap_or(usize::MAX),
350                    current: usize::try_from(self.memory_accounting().total_used_bytes())
351                        .unwrap_or(usize::MAX)
352                        .saturating_add(1),
353                }
354            })?;
355        Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
356            collection_names,
357            observe_legacy_payloads,
358            read_bytes,
359        )?)
360    }
361
362    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
363        self.inner.db.catalog_consistency_report()
364    }
365
366    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
367        crate::catalog::attention_summary(&self.catalog())
368    }
369
370    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
371        crate::catalog::collection_attention(&self.catalog())
372    }
373
374    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
375        crate::catalog::index_attention(&self.catalog())
376    }
377
378    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
379        crate::catalog::graph_projection_attention(&self.catalog())
380    }
381
382    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
383        crate::catalog::analytics_job_attention(&self.catalog())
384    }
385
386    pub fn stats(&self) -> RuntimeStats {
387        let pool = runtime_pool_lock(self);
388        RuntimeStats {
389            active_connections: pool.active,
390            idle_connections: pool.idle.len(),
391            total_checkouts: pool.total_checkouts,
392            paged_mode: self.inner.db.is_paged(),
393            started_at_unix_ms: self.inner.started_at_unix_ms,
394            store: self.inner.db.stats(),
395            system: SystemInfo::collect(),
396            result_blob_cache: self.inner.result_blob_cache.stats(),
397            kv: self.inner.kv_stats.snapshot(),
398            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
399        }
400    }
401}
402
403#[cfg(test)]
404mod security_boundary_tests {
405    use super::*;
406    use crate::application::entity::{DeleteEntityInput, PatchEntityInput};
407    use crate::application::ports::RuntimeEntityPort;
408    use crate::auth::Role;
409    use crate::runtime::execution_context::{with_snapshot_bundle, SnapshotBundle};
410    use crate::storage::{EntityData, UnifiedEntity};
411    use reddb_types::Value;
412
413    fn text_field<'a>(entity: &'a UnifiedEntity, name: &str) -> Option<&'a str> {
414        let EntityData::Row(row) = &entity.data else {
415            return None;
416        };
417        let value = row
418            .named
419            .as_ref()
420            .and_then(|named| named.get(name))
421            .or_else(|| {
422                row.schema.as_ref().and_then(|schema| {
423                    schema
424                        .iter()
425                        .position(|column| column == name)
426                        .and_then(|index| row.columns.get(index))
427                })
428            })?;
429        match value {
430            Value::Text(value) => Some(value.as_ref()),
431            _ => None,
432        }
433    }
434
435    #[test]
436    fn typed_entity_boundary_filters_before_pagination_and_hides_foreign_ids() {
437        let runtime = RedDBRuntime::in_memory().expect("runtime");
438        runtime
439            .execute_query(
440                "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) TENANT BY (tenant_id)",
441            )
442            .expect("tenant table");
443        runtime
444            .execute_query(
445                "INSERT INTO docs (id, tenant_id, body) VALUES \
446                 (1, 'acme', 'first'), (2, 'globex', 'hidden'), (3, 'acme', 'second')",
447            )
448            .expect("seed platform rows");
449
450        let unfiltered = runtime
451            .scan_collection("docs", None, 10)
452            .expect("platform scan");
453        let foreign_id = unfiltered
454            .items
455            .iter()
456            .find(|entity| text_field(entity, "tenant_id") == Some("globex"))
457            .map(|entity| entity.id)
458            .expect("foreign row");
459
460        let bundle = SnapshotBundle {
461            snapshot: None,
462            auth: Some(("alice".to_string(), Role::Write)),
463            auth_principal: Some(crate::auth::UserId::scoped("acme", "alice")),
464            tenant: Some("acme".to_string()),
465        };
466        with_snapshot_bundle(&bundle, || {
467            let first = runtime
468                .scan_collection("docs", None, 1)
469                .expect("first visible page");
470            assert_eq!(first.total, 2);
471            assert_eq!(first.items.len(), 1);
472            assert_eq!(text_field(&first.items[0], "tenant_id"), Some("acme"));
473            assert_eq!(first.next, Some(ScanCursor { offset: 1 }));
474
475            let second = runtime
476                .scan_collection("docs", first.next, 1)
477                .expect("second visible page");
478            assert_eq!(second.total, 2);
479            assert_eq!(second.items.len(), 1);
480            assert_eq!(text_field(&second.items[0], "tenant_id"), Some("acme"));
481            assert_eq!(second.next, None);
482
483            assert!(runtime
484                .get_collection_entity("docs", foreign_id)
485                .expect("typed get")
486                .is_none());
487            assert!(matches!(
488                runtime.patch_entity(PatchEntityInput {
489                    collection: "docs".to_string(),
490                    id: foreign_id,
491                    payload: crate::json::Value::Object(Default::default()),
492                    operations: Vec::new(),
493                }),
494                Err(crate::RedDBError::NotFound(_))
495            ));
496            let deleted = runtime
497                .delete_entity(DeleteEntityInput {
498                    collection: "docs".to_string(),
499                    id: foreign_id,
500                })
501                .expect("typed delete");
502            assert!(!deleted.deleted);
503        });
504
505        assert!(runtime.db().store().get("docs", foreign_id).is_some());
506    }
507
508    #[test]
509    fn typed_entity_boundary_uses_model_specific_rls_policies() {
510        let runtime = RedDBRuntime::in_memory().expect("runtime");
511        for query in [
512            "CREATE GRAPH links",
513            "INSERT INTO links NODE (label,node_type,visible) VALUES ('shown','Item',true)",
514            "INSERT INTO links NODE (label,node_type,visible) VALUES ('hidden','Item',false)",
515            "CREATE POLICY visible_nodes ON NODES OF links FOR SELECT USING (properties.visible=true)",
516            "ALTER TABLE links ENABLE ROW LEVEL SECURITY",
517            "CREATE DOCUMENT papers",
518            "INSERT INTO papers DOCUMENT VALUES ({name:'shown',visible:true})",
519            "INSERT INTO papers DOCUMENT VALUES ({name:'hidden',visible:false})",
520            "CREATE POLICY visible_documents ON DOCUMENTS OF papers FOR SELECT USING (visible=true)",
521            "ALTER TABLE papers ENABLE ROW LEVEL SECURITY",
522        ] {
523            runtime.execute_query(query).expect(query);
524        }
525
526        let bundle = SnapshotBundle {
527            snapshot: None,
528            auth: Some(("alice".to_string(), Role::Read)),
529            auth_principal: Some(crate::auth::UserId::platform("alice")),
530            tenant: None,
531        };
532        with_snapshot_bundle(&bundle, || {
533            let nodes = runtime
534                .scan_collection("links", None, 10)
535                .expect("typed graph scan");
536            assert_eq!(nodes.total, 1);
537            assert_eq!(nodes.items.len(), 1);
538
539            let documents = runtime
540                .scan_collection("papers", None, 10)
541                .expect("typed document scan");
542            assert_eq!(documents.total, 1);
543            assert_eq!(documents.items.len(), 1);
544        });
545    }
546}