Skip to main content

reddb_server/runtime/
impl_tenant_registry.rs

1//! Tenant-table registry and rehydration.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 9/10, issue #1630).
4//! Houses tenant-table rehydration (`rehydrate_tenant_tables`,
5//! `rehydrate_materialized_view_descriptors`, `rehydrate_declared_column_schemas`),
6//! registration (`register_tenant_table`, `ensure_tenant_index`, `drop_tenant_index`,
7//! `tenant_column`, `unregister_tenant_table`), and planner-stat maintenance
8//! (`refresh_table_planner_stats`, `note_table_write`).
9use super::*;
10
11impl RedDBRuntime {
12    /// Replay `tenant_tables.*.column` keys from red_config at boot so
13    /// `CREATE TABLE ... TENANT BY (col)` declarations persist across
14    /// restarts (Phase 2.5.4). Reads every row of the `red_config`
15    /// collection, picks the keys matching the tenant-marker shape,
16    /// and calls `register_tenant_table` for each.
17    ///
18    /// Safe no-op when `red_config` doesn't exist (first boot on a
19    /// fresh datadir).
20    pub(crate) fn rehydrate_tenant_tables(&self) {
21        let store = self.inner.db.store();
22        let Some(manager) = store.get_collection("red_config") else {
23            return;
24        };
25        // Replay in insertion order (SegmentManager iteration). Multiple
26        // toggles on the same table leave several rows behind — the
27        // last one processed wins because each register/unregister
28        // call overwrites the in-memory state.
29        for entity in manager.query_all(|_| true) {
30            let crate::storage::unified::entity::EntityData::Row(row) = &entity.data else {
31                continue;
32            };
33            let Some(named) = &row.named else { continue };
34            let Some(reddb_types::Value::Text(key)) = named.get("key") else {
35                continue;
36            };
37            // Shape: tenant_tables.{table}.column
38            let Some(rest) = key.strip_prefix("tenant_tables.") else {
39                continue;
40            };
41            let Some((table, suffix)) = rest.rsplit_once('.') else {
42                // Issue #205 — a `tenant_tables.*` row that doesn't
43                // split cleanly is a schema-shape regression: the
44                // metadata writer must always emit the `.column`
45                // suffix, so reaching this branch means an upgrade
46                // with incompatible state or external tampering.
47                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
48                    collection: "red_config".to_string(),
49                    detail: format!("malformed tenant_tables key: {key}"),
50                }
51                .emit_global();
52                continue;
53            };
54            if suffix != "column" {
55                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
56                    collection: "red_config".to_string(),
57                    detail: format!("unexpected tenant_tables suffix: {key}"),
58                }
59                .emit_global();
60                continue;
61            }
62            match named.get("value") {
63                Some(reddb_types::Value::Text(column)) => {
64                    self.register_tenant_table(table, column);
65                }
66                // Null / missing value = DISABLE TENANCY marker.
67                Some(reddb_types::Value::Null) | None => {
68                    self.unregister_tenant_table(table);
69                }
70                _ => {}
71            }
72        }
73    }
74
75    /// Replay every persisted `MaterializedViewDescriptor` from the
76    /// `red_materialized_view_defs` system collection (issue #593
77    /// slice 9a). For each descriptor, re-parse the original SQL,
78    /// extract the `QueryExpr::CreateView` it produced, and populate
79    /// the in-memory registries (`inner.views` and
80    /// `inner.materialized_views`) directly — no write paths run, so
81    /// rehydrate does not re-persist what it just read.
82    ///
83    /// Malformed rows (missing `name`/`source_sql`, parse errors) are
84    /// skipped with a `SchemaCorruption` operator event so a single
85    /// bad entry does not block startup.
86    pub(crate) fn rehydrate_materialized_view_descriptors(&self) -> RedDBResult<()> {
87        let store = self.inner.db.store();
88        let descriptors = crate::runtime::continuous_materialized_view::load_all(store.as_ref())?;
89        for descriptor in descriptors {
90            let parsed = match reddb_rql::parser::parse(&descriptor.source_sql) {
91                Ok(qc) => qc,
92                Err(err) => {
93                    crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
94                        collection:
95                            crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
96                                .to_string(),
97                        detail: format!(
98                            "failed to re-parse materialized-view source for {}: {err}",
99                            descriptor.name
100                        ),
101                    }
102                    .emit_global();
103                    continue;
104                }
105            };
106            let reddb_rql::ast::QueryExpr::CreateView(create) = parsed.query else {
107                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
108                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
109                        .to_string(),
110                    detail: format!(
111                        "materialized-view source for {} did not re-parse as CREATE VIEW",
112                        descriptor.name
113                    ),
114                }
115                .emit_global();
116                continue;
117            };
118            // Populate in-memory view registry.
119            let view_name = create.name.clone();
120            self.inner
121                .views
122                .write()
123                .insert(view_name.clone(), Arc::new(create));
124            // Materialized cache slot (data empty until next REFRESH).
125            use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
126            let refresh = match descriptor.refresh_every_ms {
127                Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
128                None => RefreshPolicy::Manual,
129            };
130            let def = MaterializedViewDef {
131                name: view_name.clone(),
132                query: format!("<parsed view {}>", view_name),
133                dependencies: descriptor.source_collections.clone(),
134                refresh,
135                retention_duration_ms: descriptor.retention_duration_ms,
136            };
137            self.inner.materialized_views.write().register(def);
138            if let Err(err) = self.ensure_materialized_view_backing(&view_name) {
139                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
140                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
141                        .to_string(),
142                    detail: format!(
143                        "failed to rehydrate backing collection for materialized view {view_name}: {err}"
144                    ),
145                }
146                .emit_global();
147            }
148        }
149        // A rehydrated view shape may differ from any plans the cache
150        // bootstrapped before this method ran — flush to be safe.
151        self.invalidate_plan_cache();
152        Ok(())
153    }
154
155    pub(crate) fn rehydrate_declared_column_schemas(&self) {
156        let store = self.inner.db.store();
157        for contract in self.inner.db.collection_contracts() {
158            let columns: Vec<String> = contract
159                .declared_columns
160                .iter()
161                .map(|column| column.name.clone())
162                .collect();
163            let Some(manager) = store.get_collection(&contract.name) else {
164                continue;
165            };
166            manager.set_column_schema_if_empty(columns);
167            manager.compact_recovered_rows();
168        }
169    }
170
171    /// Register a table as tenant-scoped (Phase 2.5.4). Installs the
172    /// in-memory column mapping, the implicit RLS policy, and enables
173    /// row-level security on the table. Idempotent — re-registering
174    /// the same `(table, column)` replaces the prior auto-policy.
175    pub fn register_tenant_table(&self, table: &str, column: &str) {
176        use reddb_rql::ast::{CompareOp, CreatePolicyQuery, Expr, FieldRef, Filter, Span};
177        self.inner
178            .tenant_tables
179            .write()
180            .insert(table.to_string(), column.to_string());
181
182        // Build the policy: col = CURRENT_TENANT()
183        // Uses CompareExpr so the comparison happens at runtime against
184        // the thread-local tenant value read by the CURRENT_TENANT
185        // scalar. Spans are synthetic — there's no source location for
186        // an auto-generated policy.
187        let lhs = Expr::Column {
188            field: FieldRef::TableColumn {
189                table: table.to_string(),
190                column: column.to_string(),
191            },
192            span: Span::synthetic(),
193        };
194        let rhs = Expr::FunctionCall {
195            name: "CURRENT_TENANT".to_string(),
196            args: Vec::new(),
197            span: Span::synthetic(),
198        };
199        let policy_filter = Filter::CompareExpr {
200            lhs,
201            op: CompareOp::Eq,
202            rhs,
203        };
204
205        let policy = CreatePolicyQuery {
206            name: "__tenant_iso".to_string(),
207            table: table.to_string(),
208            action: None, // None = ALL actions (SELECT/INSERT/UPDATE/DELETE)
209            role: None,   // None = every role
210            using: Box::new(policy_filter),
211            // Auto-tenancy defaults to Table targets. Collections of
212            // other kinds (graph / vector / queue / timeseries) that
213            // opt in via `ALTER ... ENABLE TENANCY` should use the
214            // matching kind — but for now we keep the auto-policy
215            // kind-agnostic so the evaluator can apply it to any
216            // entity living in the collection.
217            target_kind: reddb_rql::ast::PolicyTargetKind::Table,
218        };
219
220        // Replace any prior auto-policy for this table (column rename).
221        self.inner.rls_policies.write().insert(
222            (table.to_string(), "__tenant_iso".to_string()),
223            Arc::new(policy),
224        );
225        self.inner
226            .rls_enabled_tables
227            .write()
228            .insert(table.to_string());
229
230        // Auto-build a hash index on the tenant column. Every read/write
231        // against a tenant-scoped table carries an implicit
232        // `col = CURRENT_TENANT()` predicate from the auto-policy, so an
233        // index on that column is on the hot path of every query. Without
234        // it, every SELECT/UPDATE/DELETE degrades to a full scan.
235        self.ensure_tenant_index(table, column);
236    }
237
238    /// Auto-create the hash index that backs the tenant-iso RLS predicate.
239    /// Skipped when:
240    ///   * the column is dotted (nested path — flat secondary indices
241    ///     don't cover those today; RLS still works via the policy)
242    ///   * `__tenant_idx_{table}` already exists (idempotent on rehydrate)
243    ///   * the user already registered an index whose first column matches
244    ///     (avoids redundant duplicates of a user-defined composite)
245    fn ensure_tenant_index(&self, table: &str, column: &str) {
246        if column.contains('.') {
247            return;
248        }
249        let topology_lock = self.inner.index_store.collection_topology_lock(table);
250        let _topology_guard = topology_lock.write();
251        let index_name = format!("__tenant_idx_{table}");
252        let registry = self.inner.index_store.list_indices(table);
253        if registry.iter().any(|idx| idx.name == index_name) {
254            return;
255        }
256        if registry
257            .iter()
258            .any(|idx| idx.columns.first().map(|c| c.as_str()) == Some(column))
259        {
260            return;
261        }
262
263        let store = self.inner.db.store();
264        let Some(manager) = store.get_collection(table) else {
265            return;
266        };
267        let entities = manager.query_all(|_| true);
268        let entity_fields: Vec<(
269            crate::storage::unified::EntityId,
270            Vec<(String, reddb_types::Value)>,
271        )> = entities
272            .iter()
273            .map(|e| {
274                let fields = match &e.data {
275                    crate::storage::EntityData::Row(row) => {
276                        if let Some(ref named) = row.named {
277                            named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
278                        } else if let Some(ref schema) = row.schema {
279                            schema
280                                .iter()
281                                .zip(row.columns.iter())
282                                .map(|(k, v)| (k.clone(), v.clone()))
283                                .collect()
284                        } else {
285                            Vec::new()
286                        }
287                    }
288                    crate::storage::EntityData::Node(node) => node
289                        .properties
290                        .iter()
291                        .map(|(k, v)| (k.clone(), v.clone()))
292                        .collect(),
293                    _ => Vec::new(),
294                };
295                (e.id, fields)
296            })
297            .collect();
298
299        let columns = vec![column.to_string()];
300        if self
301            .inner
302            .index_store
303            .create_index(
304                &index_name,
305                table,
306                &columns,
307                super::index_store::IndexMethodKind::Hash,
308                false,
309                &entity_fields,
310            )
311            .is_err()
312        {
313            return;
314        }
315        self.inner
316            .index_store
317            .register(super::index_store::RegisteredIndex {
318                name: index_name,
319                collection: table.to_string(),
320                columns,
321                method: super::index_store::IndexMethodKind::Hash,
322                unique: false,
323            });
324        self.invalidate_plan_cache();
325    }
326
327    /// Drop the auto-generated tenant index, if one exists. Called from
328    /// `unregister_tenant_table` so DISABLE TENANCY / DROP TABLE clean up.
329    fn drop_tenant_index(&self, table: &str) {
330        let topology_lock = self.inner.index_store.collection_topology_lock(table);
331        let _topology_guard = topology_lock.write();
332        let index_name = format!("__tenant_idx_{table}");
333        self.inner.index_store.drop_index(&index_name, table);
334    }
335
336    /// Retrieve the tenant column for a table, if any (Phase 2.5.4).
337    /// Used by the INSERT auto-fill path to know which column to
338    /// populate with `current_tenant()` when the user didn't name it.
339    pub fn tenant_column(&self, table: &str) -> Option<String> {
340        self.inner.tenant_tables.read().get(table).cloned()
341    }
342
343    /// Remove a table's tenant registration (Phase 2.5.4). Called by
344    /// DROP TABLE / ALTER TABLE DISABLE TENANCY. Removes the auto-policy
345    /// but leaves any user-installed explicit policies intact.
346    pub fn unregister_tenant_table(&self, table: &str) {
347        self.inner.tenant_tables.write().remove(table);
348        self.inner
349            .rls_policies
350            .write()
351            .remove(&(table.to_string(), "__tenant_iso".to_string()));
352        self.drop_tenant_index(table);
353        // Only clear RLS enablement if no other policies remain.
354        let has_other_policies = self
355            .inner
356            .rls_policies
357            .read()
358            .keys()
359            .any(|(t, _)| t == table);
360        if !has_other_policies {
361            self.inner.rls_enabled_tables.write().remove(table);
362        }
363    }
364
365    pub(crate) fn refresh_table_planner_stats(&self, table: &str) {
366        let store = self.inner.db.store();
367        if let Some(stats) =
368            crate::storage::query::planner::stats_catalog::analyze_collection(store.as_ref(), table)
369        {
370            crate::storage::query::planner::stats_catalog::persist_table_stats(
371                store.as_ref(),
372                &stats,
373            );
374        } else {
375            crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
376        }
377        self.invalidate_plan_cache();
378    }
379
380    pub(crate) fn note_table_write(&self, table: &str) {
381        // Skip the write lock when the table is already marked
382        // dirty. With single-row UPDATEs in a loop this used to
383        // grab the planner_dirty_tables write lock N times even
384        // though the first call already flipped the flag.
385        let already_dirty = self.inner.planner_dirty_tables.read().contains(table);
386        if !already_dirty {
387            self.inner
388                .planner_dirty_tables
389                .write()
390                .insert(table.to_string());
391        }
392        self.invalidate_result_cache_for_table(table);
393    }
394}