micromegas_analytics/lakehouse/view_registry.rs
1//! `ViewRegistry`: a shared `Arc<ViewFactory>` (the code-driven base plus every DDL-defined global
2//! view) rebuilt from `lakehouse_view_set_definitions` on a timer, so `telemetry-maintenance-srv`
3//! and `flight-sql-srv` both start reading/materializing a new or changed view set without a
4//! restart. Shaped after `QueryDenyList`: a Postgres-backed store, an immutable compiled snapshot
5//! behind a lock, a `refresh`/`reload` that logs and meters its own failures, and
6//! `spawn_refresh_task`.
7
8use super::{
9 session_configurator::SessionConfigurator,
10 view_definition::{build_sql_batch_view, validate_view_definition},
11 view_definition_store::{ViewDefinitionRow, ViewDefinitionStore},
12 view_factory::ViewFactory,
13};
14use anyhow::Result;
15use datafusion::execution::runtime_env::RuntimeEnv;
16use micromegas_ingestion::data_lake_connection::DataLakeConnection;
17use micromegas_tracing::prelude::*;
18use std::collections::HashSet;
19use std::future::Future;
20use std::hash::{DefaultHasher, Hash, Hasher};
21use std::sync::{Arc, RwLock};
22use std::time::Duration;
23
24/// `{prefix}`-less env var: how often `spawn_refresh_task` calls `reload()`, and the bound on
25/// cross-replica propagation of a definition change made on a different node (the node that
26/// executed the DDL statement reloads inline instead, see `rust/public/src/servers/view_ddl.rs`).
27pub const MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS: &str =
28 "MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS";
29/// Default value of [`MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS`].
30pub const DEFAULT_REFRESH_SECONDS: u64 = 60;
31
32fn refresh_interval() -> Duration {
33 let secs = std::env::var(MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS)
34 .ok()
35 .and_then(|s| s.parse::<u64>().ok())
36 .unwrap_or(DEFAULT_REFRESH_SECONDS)
37 .max(1);
38 Duration::from_secs(secs)
39}
40
41/// Hashes the `(view_set_name, updated_at)` tuples of `rows`, in the order given (already
42/// `(update_group, view_set_name)`-ordered by `ViewDefinitionStore::list`) -- what `reload()`
43/// compares against `loaded_digest` to short-circuit a steady-state tick to one `SELECT`.
44fn digest_of(rows: &[ViewDefinitionRow]) -> u64 {
45 let mut hasher = DefaultHasher::new();
46 for row in rows {
47 row.view_set_name.hash(&mut hasher);
48 row.updated_at
49 .timestamp_nanos_opt()
50 .unwrap_or(0)
51 .hash(&mut hasher);
52 }
53 hasher.finish()
54}
55
56/// What a [`build_factory`] call is for, which decides how a skipped definition is reported.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum BuildPurpose {
59 /// The snapshot `current()` will serve: a skipped definition is real degradation -- something
60 /// that used to work no longer does, or never did -- so it is `warn!`-logged and metered.
61 LiveSnapshot,
62 /// A speculative build the DDL executor runs inside its own transaction, on row sets that may
63 /// deliberately be missing a view another one reads. A skip here is the expected signal, not
64 /// degradation: it is returned in [`BuildFailure`] for the caller to act on -- either to
65 /// refuse the statement, naming the definition and reason, or to ignore a row that was already
66 /// broken before the mutation. Logging it would report a rejected statement, or a pre-existing
67 /// breakage the live build already reports every tick, as a fresh registry failure.
68 Probe,
69}
70
71/// A definition [`build_factory`] skipped, with the reason it was skipped, so a [`BuildPurpose::
72/// Probe`] caller can name both in the error it raises.
73#[derive(Clone, Debug)]
74pub struct BuildFailure {
75 pub view_set_name: String,
76 /// The `{:#}` rendering of the parse/build/validation error, alternate-formatted to keep the
77 /// anyhow context chain.
78 pub error: String,
79}
80
81impl std::fmt::Display for BuildFailure {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{} ({})", self.view_set_name, self.error)
84 }
85}
86
87fn skip(
88 purpose: BuildPurpose,
89 view_set_name: String,
90 stage: &str,
91 e: anyhow::Error,
92) -> BuildFailure {
93 let error = format!("{e:#}");
94 if purpose == BuildPurpose::LiveSnapshot {
95 warn!("view_registry: '{view_set_name}' failed {stage}, skipping: {error}");
96 imetric!(
97 "view_definition_load_failure",
98 "count",
99 view_set_name = view_set_name,
100 1_u64
101 );
102 }
103 BuildFailure {
104 view_set_name,
105 error,
106 }
107}
108
109/// Builds a factory from an explicit row set instead of `store.list()`, so a caller already
110/// holding rows inside an open transaction (the DDL executor's dependent-protection check) can
111/// validate against them without a second, pool-backed read that would not see its own
112/// uncommitted write. `reload()` calls this too, after its own `store.list()`. `async` because
113/// building each row's `SqlBatchView` is itself `async` and needs `runtime`/`lake`/
114/// `session_configurator` to plan the extract query.
115///
116/// Builds incrementally, in the order `rows` is given (`(update_group, view_set_name)`): `let mut
117/// factory = (**base).clone();` then, per row, build and validate its `SqlBatchView` against
118/// `Arc::new(factory.clone())` -- the built-ins plus every already-folded-in row -- and only then
119/// `factory.add_global_view(...)`. A row that fails to parse, build, or validate is **skipped, not
120/// fatal**: collected into the returned failed-set with its reason, so one broken definition
121/// cannot take out every other view set. Whether that skip is also logged and metered is
122/// `purpose`'s call.
123pub async fn build_factory(
124 base: &Arc<ViewFactory>,
125 rows: &[ViewDefinitionRow],
126 runtime: Arc<RuntimeEnv>,
127 lake: Arc<DataLakeConnection>,
128 session_configurator: Arc<dyn SessionConfigurator>,
129 purpose: BuildPurpose,
130) -> Result<(Arc<ViewFactory>, Vec<BuildFailure>)> {
131 let mut factory = (**base).clone();
132 let mut failed = Vec::new();
133 for row in rows {
134 let name = row.view_set_name.clone();
135 let def = match row.clone().into_definition() {
136 Ok(def) => def,
137 Err(e) => {
138 failed.push(skip(purpose, name, "to parse", e));
139 continue;
140 }
141 };
142 let factory_so_far = Arc::new(factory.clone());
143 let view = match build_sql_batch_view(
144 &def,
145 runtime.clone(),
146 lake.clone(),
147 factory_so_far.clone(),
148 session_configurator.clone(),
149 )
150 .await
151 {
152 Ok(view) => view,
153 Err(e) => {
154 failed.push(skip(purpose, name, "to build", e));
155 continue;
156 }
157 };
158 if let Err(e) = validate_view_definition(
159 &def,
160 &view,
161 &factory_so_far,
162 runtime.clone(),
163 lake.clone(),
164 session_configurator.clone(),
165 )
166 .await
167 {
168 failed.push(skip(purpose, name, "validation", e));
169 continue;
170 }
171 factory.add_global_view(Arc::new(view));
172 }
173 Ok((Arc::new(factory), failed))
174}
175
176/// A shared `Arc<ViewFactory>` (base + N DDL-defined `SqlBatchView`s) rebuilt from
177/// `lakehouse_view_set_definitions` on a timer. See the module doc comment.
178pub struct ViewRegistry {
179 base: Arc<ViewFactory>,
180 store: Arc<dyn ViewDefinitionStore>,
181 runtime: Arc<RuntimeEnv>,
182 lake: Arc<DataLakeConnection>,
183 session_configurator: Arc<dyn SessionConfigurator>,
184 current: RwLock<Arc<ViewFactory>>,
185 loaded_digest: RwLock<Option<u64>>,
186 failed_view_sets: RwLock<Vec<String>>,
187 /// Held across all of `reload()` -- list, build, and swap -- not just the swap. Without this,
188 /// a periodic reload that listed rows before a DDL-triggered reload could block behind the
189 /// DDL's own reload and then swap the pre-mutation factory back in over the DDL executor's
190 /// post-commit one, a lost update that would stay live for up to
191 /// `MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS`. Mirrors `QueryDenyList::write_lock`'s
192 /// rationale.
193 reload_mutex: tokio::sync::Mutex<()>,
194}
195
196impl std::fmt::Debug for ViewRegistry {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("ViewRegistry")
199 .field(
200 "loaded_views",
201 &self.current.read().expect("lock").get_global_views().len(),
202 )
203 .field(
204 "failed_view_sets",
205 &self.failed_view_sets.read().expect("lock").len(),
206 )
207 .finish()
208 }
209}
210
211impl ViewRegistry {
212 pub fn new(
213 base: Arc<ViewFactory>,
214 store: Arc<dyn ViewDefinitionStore>,
215 runtime: Arc<RuntimeEnv>,
216 lake: Arc<DataLakeConnection>,
217 session_configurator: Arc<dyn SessionConfigurator>,
218 ) -> Self {
219 Self {
220 current: RwLock::new(base.clone()),
221 base,
222 store,
223 runtime,
224 lake,
225 session_configurator,
226 loaded_digest: RwLock::new(None),
227 failed_view_sets: RwLock::new(Vec::new()),
228 reload_mutex: tokio::sync::Mutex::new(()),
229 }
230 }
231
232 /// The current snapshot: the base factory before the first successful `reload()`, or the base
233 /// plus every definition that built and validated on the last successful build afterward.
234 pub fn current(&self) -> Arc<ViewFactory> {
235 self.current.read().expect("lock").clone()
236 }
237
238 /// Names skipped by the last build, for introspection/tests.
239 pub fn failed_view_sets(&self) -> Vec<String> {
240 self.failed_view_sets.read().expect("lock").clone()
241 }
242
243 /// Thin wrapper over [`build_factory`] supplying this registry's `base`/`runtime`/`lake`/
244 /// `session_configurator`.
245 pub async fn build_from_rows(
246 &self,
247 rows: &[ViewDefinitionRow],
248 purpose: BuildPurpose,
249 ) -> Result<(Arc<ViewFactory>, Vec<BuildFailure>)> {
250 build_factory(
251 &self.base,
252 rows,
253 self.runtime.clone(),
254 self.lake.clone(),
255 self.session_configurator.clone(),
256 purpose,
257 )
258 .await
259 }
260
261 /// The dependent-protection refusal: builds a factory from `pre_rows` and one from
262 /// `post_rows`, then refuses with a named error if any definition present in `post_rows` --
263 /// including the row just written -- both fails to build against `post_rows` and was not
264 /// already in the pre-mutation failed set. Exact rather than textual: it uses the real
265 /// planner, so it catches a removed column as readily as a removed view set.
266 ///
267 /// Both builds are [`BuildPurpose::Probe`]s: a skip is what this check is looking for, so it
268 /// belongs in the refusal the caller returns -- with the planner's own reason, the only place
269 /// the admin can now read it -- not in the service log as a registry failure.
270 pub async fn check_dependents_survive(
271 &self,
272 pre_rows: &[ViewDefinitionRow],
273 post_rows: &[ViewDefinitionRow],
274 ) -> Result<()> {
275 let (_, pre_failed) = self.build_from_rows(pre_rows, BuildPurpose::Probe).await?;
276 let (_, post_failed) = self.build_from_rows(post_rows, BuildPurpose::Probe).await?;
277 let pre_failed_set: HashSet<&str> = pre_failed
278 .iter()
279 .map(|f| f.view_set_name.as_str())
280 .collect();
281 let newly_broken: Vec<String> = post_failed
282 .iter()
283 .filter(|f| !pre_failed_set.contains(f.view_set_name.as_str()))
284 .map(BuildFailure::to_string)
285 .collect();
286 if !newly_broken.is_empty() {
287 anyhow::bail!(
288 "this change would break the following view definition(s), which built \
289 successfully before it: {}",
290 newly_broken.join(", ")
291 );
292 }
293 Ok(())
294 }
295
296 /// Refreshes `current()` from `lakehouse_view_set_definitions`.
297 ///
298 /// 0. Acquires `reload_mutex` and holds it through every step below.
299 /// 1. `store.list()` -> rows ordered by `(update_group, view_set_name)`.
300 /// 2. Hashes the `(view_set_name, updated_at)` tuples; if unchanged from `loaded_digest`
301 /// **and** the previous build's failed set was empty, returns -- one `SELECT`, no
302 /// planning. A digest match after a failed build does *not* short-circuit, so a row that
303 /// failed for a transient reason is retried every tick.
304 /// 3. `build_factory(...)`.
305 /// 4. A row that fails to build or validate is skipped, not fatal (see [`build_factory`]).
306 /// 5. Swaps `current`, updates `loaded_digest` and `failed_view_sets`.
307 pub async fn reload(&self) -> Result<()> {
308 let _guard = self.reload_mutex.lock().await;
309 let rows = self.store.list().await?;
310 let digest = digest_of(&rows);
311 let previous_build_was_healthy = self.failed_view_sets.read().expect("lock").is_empty();
312 if previous_build_was_healthy && Some(digest) == *self.loaded_digest.read().expect("lock") {
313 return Ok(());
314 }
315 let (factory, failed) = self
316 .build_from_rows(&rows, BuildPurpose::LiveSnapshot)
317 .await?;
318 *self.current.write().expect("lock") = factory;
319 *self.loaded_digest.write().expect("lock") = Some(digest);
320 *self.failed_view_sets.write().expect("lock") = failed
321 .into_iter()
322 .map(|f| f.view_set_name)
323 .collect::<Vec<_>>();
324 Ok(())
325 }
326
327 /// Spawns the background task that calls [`Self::reload`] once immediately and then on every
328 /// [`MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS`] tick, until `shutdown` resolves. A failed
329 /// `reload()` is `warn!`-logged here and discarded -- the previous snapshot is kept (the same
330 /// fail-open treatment `QueryDenyList::spawn_refresh_task` gives a failed periodic refresh).
331 pub fn spawn_refresh_task(
332 self: Arc<Self>,
333 shutdown: impl Future<Output = ()> + Send + 'static,
334 ) {
335 tokio::spawn(async move {
336 let mut shutdown = Box::pin(shutdown);
337 loop {
338 if let Err(e) = self.reload().await {
339 warn!("view_registry: reload failed, keeping previous snapshot: {e:#}");
340 }
341 tokio::select! {
342 _ = &mut shutdown => break,
343 _ = tokio::time::sleep(refresh_interval()) => {}
344 }
345 }
346 });
347 }
348}