Skip to main content

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}