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}