From bd10e8bd781e9345706a6d364e3119125fa33b59 Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Fri, 9 Oct 2026 03:10:55 -0700 Subject: [PATCH] fix: refuse a materialized view whose sources lead back to itself Creating a view follows its source through each view's stored coordinate and refuses when the walk reaches the name being created. --- rust/lancedb/src/materialized_view.rs | 98 +++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/rust/lancedb/src/materialized_view.rs b/rust/lancedb/src/materialized_view.rs index c04a6cbbe..95e3baac7 100644 --- a/rust/lancedb/src/materialized_view.rs +++ b/rust/lancedb/src/materialized_view.rs @@ -42,6 +42,9 @@ pub use refresh::{RefreshMaterializedViewResult, RefreshMode}; /// Schema metadata key holding the view definition; see [`DEFINITION_FORMAT`]. pub const DEFINITION_META_KEY: &str = "mv.definition"; +/// How many views may stand between a view and a plain table. +const MAX_SOURCE_DEPTH: usize = 32; + /// Field metadata namespace for declarations about schema structure, such as /// an unenforced primary key. const SCHEMA_DECLARATION_META_PREFIX: &str = "lance-schema:"; @@ -1238,6 +1241,7 @@ impl PreparedDeclaration { namespace_path: &[String], name: &str, ) -> Result { + ensure_no_source_cycle(&self.database, namespace_path, name, &self.definition).await?; let empty: Vec> = vec![]; let reader: Box = @@ -1254,6 +1258,65 @@ impl PreparedDeclaration { } } +/// Refuse to create `name` when following its source through each view's +/// stored definition leads back to `name`: with no plain table beneath it, +/// such a view would only ever refresh from itself. The walk follows the +/// coordinates the views already record, so it opens one table per level. +async fn ensure_no_source_cycle( + database: &Arc, + namespace_path: &[String], + name: &str, + definition: &MaterializedViewDefinition, +) -> Result<()> { + let mut path = vec![name.to_string()]; + let mut next = ( + definition.source_namespace.clone(), + definition.source_table.clone(), + ); + for _ in 0..MAX_SOURCE_DEPTH { + let (namespace, source) = next; + path.push(source.clone()); + if namespace == namespace_path && source == name { + return Err(Error::InvalidInput { + message: format!( + "materialized view '{name}' would read itself: {}", + path.join(" -> ") + ), + }); + } + let table = match database + .open_table(OpenTableRequest { + name: source, + namespace_path: namespace, + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, + }) + .await + { + Ok(table) => table, + // A missing source ends the chain, so it cannot lead back here. + Err(Error::TableNotFound { .. }) => return Ok(()), + Err(e) => return Err(e), + }; + let schema = table.schema().await?; + // Only a definition this version reads can be followed. + match read_definition(schema.metadata()) { + Ok(Some(StoredDefinition::Query(upstream))) => { + next = (upstream.source_namespace, upstream.source_table); + } + _ => return Ok(()), + } + } + Err(Error::InvalidInput { + message: format!( + "materialized view '{name}' would nest deeper than {MAX_SOURCE_DEPTH} views" + ), + }) +} + /// Column definitions are positional over the view schema: carry each /// field's entry to its place in `fields`, physical for a field that had none. fn rewrite_column_definitions( @@ -2017,6 +2080,41 @@ mod tests { } } + #[tokio::test] + async fn a_view_cannot_lead_back_to_itself() { + let conn = connect("memory://").execute().await.unwrap(); + let rows = || record_batch!(("x", Int32, [1, 2])).unwrap(); + conn.create_table("src", rows()).execute().await.unwrap(); + conn.create_materialized_view("m", "src") + .execute() + .await + .unwrap(); + conn.create_materialized_view("m2", "m") + .execute() + .await + .unwrap(); + conn.drop_table("src", &[]).await.unwrap(); + + let err = conn + .create_materialized_view("src", "m2") + .execute() + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("materialized view 'src' would read itself: src -> m2 -> m -> src"), + "{err}" + ); + assert!(conn.open_table("src").execute().await.is_err()); + + // Back under a plain table, the same chain is fine to extend. + conn.create_table("src", rows()).execute().await.unwrap(); + conn.create_materialized_view("m3", "m2") + .execute() + .await + .unwrap(); + } + #[tokio::test] async fn direct_writes_to_a_view_are_refused() { use arrow_schema::Schema;