This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 98675595 fix(datafusion): apply dynamic options to vector search (#623)
98675595 is described below

commit 98675595db47d9c29735b2792547cde8395225ac
Author: shyjsarah <[email protected]>
AuthorDate: Thu Jul 30 09:11:41 2026 +0800

    fix(datafusion): apply dynamic options to vector search (#623)
---
 crates/integrations/datafusion/src/sql_context.rs  |  15 ++-
 .../integrations/datafusion/src/vector_search.rs   | 137 ++++++++++++++++++++-
 crates/paimon/src/table/table_scan.rs              |  39 +-----
 crates/paimon/src/table/time_travel.rs             |  34 ++++-
 crates/paimon/src/table/vector_search_builder.rs   |  44 ++++++-
 5 files changed, 225 insertions(+), 44 deletions(-)

diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 10532c57..d5a92f09 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -200,7 +200,12 @@ impl SQLContext {
                 Some(session_state),
             )),
         );
-        register_table_functions(&self.ctx, &catalog, 
default_db.unwrap_or("default"));
+        register_table_functions(
+            &self.ctx,
+            &catalog,
+            default_db.unwrap_or("default"),
+            self.dynamic_options.clone(),
+        );
         self.catalogs.insert(catalog_name.clone(), catalog);
         if is_first {
             self.set_current_catalog(catalog_name).await?;
@@ -3311,9 +3316,15 @@ fn register_table_functions(
     ctx: &SessionContext,
     catalog: &Arc<dyn Catalog>,
     default_database: &str,
+    dynamic_options: DynamicOptions,
 ) {
     crate::blob_view::register_blob_view(ctx, Arc::clone(catalog), 
default_database);
-    crate::vector_search::register_vector_search(ctx, Arc::clone(catalog), 
default_database);
+    crate::vector_search::register_vector_search_with_dynamic_options(
+        ctx,
+        Arc::clone(catalog),
+        default_database,
+        dynamic_options,
+    );
     #[cfg(feature = "fulltext")]
     crate::full_text_search::register_full_text_search(ctx, 
Arc::clone(catalog), default_database);
     crate::hybrid_search::register_hybrid_search(ctx, Arc::clone(catalog), 
default_database);
diff --git a/crates/integrations/datafusion/src/vector_search.rs 
b/crates/integrations/datafusion/src/vector_search.rs
index cdfed832..5a38942f 100644
--- a/crates/integrations/datafusion/src/vector_search.rs
+++ b/crates/integrations/datafusion/src/vector_search.rs
@@ -55,6 +55,7 @@ use crate::table_function_args::{
     extract_int_literal, extract_string_literal, parse_table_identifier,
 };
 use crate::table_loader::load_data_table_for_read;
+use crate::DynamicOptions;
 
 const FUNCTION_NAME: &str = "vector_search";
 
@@ -62,16 +63,30 @@ pub fn register_vector_search(
     ctx: &SessionContext,
     catalog: Arc<dyn Catalog>,
     default_database: &str,
+) {
+    register_vector_search_with_dynamic_options(ctx, catalog, 
default_database, Default::default());
+}
+
+pub(crate) fn register_vector_search_with_dynamic_options(
+    ctx: &SessionContext,
+    catalog: Arc<dyn Catalog>,
+    default_database: &str,
+    dynamic_options: DynamicOptions,
 ) {
     ctx.register_udtf(
         "vector_search",
-        Arc::new(VectorSearchFunction::new(catalog, default_database)),
+        Arc::new(VectorSearchFunction::new_with_dynamic_options(
+            catalog,
+            default_database,
+            dynamic_options,
+        )),
     );
 }
 
 pub struct VectorSearchFunction {
     catalog: Arc<dyn Catalog>,
     default_database: String,
+    dynamic_options: DynamicOptions,
 }
 
 impl Debug for VectorSearchFunction {
@@ -84,9 +99,18 @@ impl Debug for VectorSearchFunction {
 
 impl VectorSearchFunction {
     pub fn new(catalog: Arc<dyn Catalog>, default_database: &str) -> Self {
+        Self::new_with_dynamic_options(catalog, default_database, 
Default::default())
+    }
+
+    pub(crate) fn new_with_dynamic_options(
+        catalog: Arc<dyn Catalog>,
+        default_database: &str,
+        dynamic_options: DynamicOptions,
+    ) -> Self {
         Self {
             catalog,
             default_database: default_database.to_string(),
+            dynamic_options,
         }
     }
 }
@@ -113,8 +137,20 @@ impl TableFunctionImpl for VectorSearchFunction {
             parse_table_identifier(FUNCTION_NAME, &table_name, 
&self.default_database)?;
 
         let catalog = Arc::clone(&self.catalog);
+        let dynamic_options = self.dynamic_options.read().unwrap().clone();
         let table = block_on_with_runtime(
-            async move { load_data_table_for_read(&catalog, &identifier, 
FUNCTION_NAME).await },
+            async move {
+                let table = load_data_table_for_read(&catalog, &identifier, 
FUNCTION_NAME).await?;
+                let table = if dynamic_options.is_empty() {
+                    table
+                } else {
+                    table
+                        .copy_with_time_travel(dynamic_options)
+                        .await
+                        .map_err(to_datafusion_error)?
+                };
+                Ok::<_, DataFusionError>(table)
+            },
             "vector_search: catalog access thread panicked",
         )?;
 
@@ -535,3 +571,100 @@ fn gather_rows_by_rank(
     RecordBatch::try_new_with_options(Arc::clone(output_schema), columns, 
&options)
         .map_err(DataFusionError::from)
 }
+
+#[cfg(test)]
+mod tests {
+    use datafusion::catalog::TableFunctionArgs;
+    use datafusion::logical_expr::lit;
+    use paimon::spec::SCAN_VERSION_OPTION;
+    use paimon::{CatalogOptions, FileSystemCatalog, Options};
+
+    use super::*;
+    use crate::SQLContext;
+
+    #[tokio::test]
+    async fn test_vector_search_applies_supported_session_dynamic_options() {
+        let temp_dir = tempfile::tempdir().unwrap();
+        let mut catalog_options = Options::new();
+        catalog_options.set(
+            CatalogOptions::WAREHOUSE,
+            format!("file://{}", temp_dir.path().display()),
+        );
+        let catalog = 
Arc::new(FileSystemCatalog::new(catalog_options).unwrap());
+
+        let mut sql_context = SQLContext::new();
+        sql_context
+            .register_catalog("paimon", catalog)
+            .await
+            .unwrap();
+        sql_context
+            .sql(
+                "CREATE TABLE paimon.default.vector_blob (\
+                    id INT, \
+                    embedding ARRAY<FLOAT>, \
+                    picture BLOB\
+                ) WITH (\
+                    'data-evolution.enabled' = 'true', \
+                    'row-tracking.enabled' = 'true'\
+                )",
+            )
+            .await
+            .unwrap();
+        sql_context
+            .sql("INSERT INTO paimon.default.vector_blob (id) VALUES (1)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        sql_context
+            .sql("SET 'paimon.blob-as-descriptor' = 'true'")
+            .await
+            .unwrap();
+        sql_context
+            .sql("SET 'paimon.scan.version' = '1'")
+            .await
+            .unwrap();
+
+        let state = sql_context.ctx().state();
+        let table_function = state
+            .table_functions()
+            .get(FUNCTION_NAME)
+            .expect("vector_search should be registered");
+        let args = [
+            lit("paimon.default.vector_blob"),
+            lit("embedding"),
+            lit("[1.0]"),
+            lit(1_i64),
+        ];
+        let provider = table_function
+            .create_table_provider_with_args(TableFunctionArgs::new(&args, 
&state))
+            .unwrap();
+        let provider = provider
+            .downcast_ref::<VectorSearchTableProvider>()
+            .expect("vector_search should return its table provider");
+
+        assert!(
+            
CoreOptions::new(provider.inner.table().schema().options()).blob_as_descriptor(),
+            "vector_search should apply session dynamic options to the loaded 
table"
+        );
+        assert!(
+            provider
+                .inner
+                .table()
+                .schema()
+                .options()
+                .contains_key(SCAN_VERSION_OPTION),
+            "vector_search should keep session time-travel options"
+        );
+        assert_eq!(
+            provider
+                .inner
+                .table()
+                .travel_snapshot()
+                .map(|snapshot| snapshot.id()),
+            Some(1),
+            "vector_search should resolve the session time-travel snapshot"
+        );
+    }
+}
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index b44429ef..fa9b7100 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1122,7 +1122,7 @@ impl<'a> PaimonTableScan<'a> {
     pub async fn plan(&self) -> crate::Result<Plan> {
         self.ensure_query_auth_allowed()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
-        let snapshot = match self.resolve_snapshot().await? {
+        let snapshot = match 
super::time_travel::resolve_snapshot(self.table).await? {
             Some(snapshot) => snapshot,
             None => return Ok(Plan::new(Vec::new())),
         };
@@ -1138,7 +1138,7 @@ impl<'a> PaimonTableScan<'a> {
             ..Default::default()
         };
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
-        let snapshot = match self.resolve_snapshot().await? {
+        let snapshot = match 
super::time_travel::resolve_snapshot(self.table).await? {
             Some(snapshot) => snapshot,
             None => return Ok((Plan::new(Vec::new()), trace)),
         };
@@ -1164,41 +1164,6 @@ impl<'a> PaimonTableScan<'a> {
         Ok(self.projected_read_field_ids.clone())
     }
 
-    async fn resolve_snapshot(&self) -> crate::Result<Option<Snapshot>> {
-        // A table copy produced by `copy_with_time_travel` already resolved
-        // the selector in its options; reuse it instead of re-reading
-        // tag/snapshot files on every plan.
-        if let Some(snapshot) = self.table.travel_snapshot() {
-            return Ok(Some(snapshot.clone()));
-        }
-        // A time-travelled schema without its resolved snapshot means the
-        // selector was changed after the travel (`copy_with_options`).
-        // Resolving the new selector here would evolve a different snapshot's
-        // files to the stale historical schema, so fail instead.
-        if self.table.is_time_traveled() {
-            return Err(crate::Error::DataInvalid {
-                message: "Table options changed after time travel; \
-                          use copy_with_time_travel to re-resolve the snapshot 
and schema"
-                    .to_string(),
-                source: None,
-            });
-        }
-
-        match super::time_travel::travel_to_snapshot(
-            &self.table.snapshot_manager(),
-            &self.table.tag_manager(),
-            self.table.schema().options(),
-        )
-        .await?
-        {
-            Some(snapshot) => Ok(Some(snapshot)),
-            None => {
-                let snapshot_manager = self.table.snapshot_manager();
-                snapshot_manager.get_latest_snapshot().await
-            }
-        }
-    }
-
     /// Apply a limit-pushdown hint to the generated splits.
     ///
     /// Mirrors Java `DataTableBatchScan#applyPushDownLimit`: splits whose
diff --git a/crates/paimon/src/table/time_travel.rs 
b/crates/paimon/src/table/time_travel.rs
index a70f6c10..3a389e33 100644
--- a/crates/paimon/src/table/time_travel.rs
+++ b/crates/paimon/src/table/time_travel.rs
@@ -18,8 +18,7 @@
 //! Snapshot resolution for time travel, mirroring Java `TimeTravelUtil`.
 
 use crate::spec::{CoreOptions, Snapshot, TimeTravelSelector};
-use crate::table::SnapshotManager;
-use crate::table::TagManager;
+use crate::table::{SnapshotManager, Table, TagManager};
 use crate::Error;
 use std::collections::HashMap;
 
@@ -93,6 +92,37 @@ pub(crate) async fn travel_to_snapshot(
     }
 }
 
+/// Resolve the snapshot a read should use, including the latest-snapshot 
fallback.
+///
+/// Reuses a snapshot cached by [`Table::copy_with_time_travel`] so every read 
path
+/// observes the same snapshot/schema pair. A historical schema whose selector 
was
+/// subsequently changed is rejected instead of mixing that stale schema with a
+/// different snapshot.
+pub(crate) async fn resolve_snapshot(table: &Table) -> 
crate::Result<Option<Snapshot>> {
+    if let Some(snapshot) = table.travel_snapshot() {
+        return Ok(Some(snapshot.clone()));
+    }
+    if table.is_time_traveled() {
+        return Err(Error::DataInvalid {
+            message: "Table options changed after time travel; \
+                      use copy_with_time_travel to re-resolve the snapshot and 
schema"
+                .to_string(),
+            source: None,
+        });
+    }
+
+    match travel_to_snapshot(
+        &table.snapshot_manager(),
+        &table.tag_manager(),
+        table.schema().options(),
+    )
+    .await?
+    {
+        Some(snapshot) => Ok(Some(snapshot)),
+        None => table.snapshot_manager().get_latest_snapshot().await,
+    }
+}
+
 /// Fetch a tag known to exist, mapping an unexpectedly-missing tag to an 
error.
 async fn resolve_tag(tag_manager: &TagManager, name: &str) -> 
crate::Result<Snapshot> {
     match tag_manager.get(name).await? {
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 2c60aa9a..82183f38 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -1129,7 +1129,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
 
         let snapshot_manager = self.table.snapshot_manager();
 
-        let snapshot = match snapshot_manager.get_latest_snapshot().await? {
+        let snapshot = match 
crate::table::time_travel::resolve_snapshot(self.table).await? {
             Some(s) => s,
             None => return Ok(vec![SearchResult::empty(); 
vector_searches.len()]),
         };
@@ -5763,6 +5763,48 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn de_vector_search_uses_time_travel_snapshot() {
+        let table = de_vector_table().await;
+        let latest = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0, 0.0])
+            .with_limit(3)
+            .execute_scored()
+            .await
+            .unwrap();
+        assert!(
+            !latest.is_empty(),
+            "latest snapshot should contain the committed vector index"
+        );
+
+        let traveled = table
+            .copy_with_time_travel(HashMap::from([(
+                crate::spec::SCAN_VERSION_OPTION.to_string(),
+                "1".to_string(),
+            )]))
+            .await
+            .unwrap();
+        assert_eq!(
+            traveled.travel_snapshot().map(|snapshot| snapshot.id()),
+            Some(1)
+        );
+
+        let historical = traveled
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0, 0.0])
+            .with_limit(3)
+            .execute_scored()
+            .await
+            .unwrap();
+        assert!(
+            historical.is_empty(),
+            "snapshot 1 predates the vector index and should return no hits"
+        );
+    }
+
     #[tokio::test]
     async fn de_execute_read_with_filter_fails_loud() {
         // A filter on the data-evolution path is unsupported (the DE path 
never

Reply via email to