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 024739cd feat(datafusion): support snapshot time travel for external 
engines (#1113)
024739cd is described below

commit 024739cd26907749ec21a20df17bc31e0dcd88eb
Author: Jiajia Li <[email protected]>
AuthorDate: Tue Oct 13 15:16:17 2026 +0800

    feat(datafusion): support snapshot time travel for external engines (#1113)
---
 crates/integrations/datafusion/README.md           |  12 +
 crates/integrations/datafusion/src/catalog.rs      | 139 ++++-
 .../datafusion/src/relation_planner.rs             |  77 +--
 crates/integrations/datafusion/src/sql_context.rs  | 185 ++++---
 crates/integrations/datafusion/src/table_loader.rs |  37 +-
 .../datafusion/tests/table_type_routing.rs         | 578 +++++++++++++++++++++
 6 files changed, 886 insertions(+), 142 deletions(-)

diff --git a/crates/integrations/datafusion/README.md 
b/crates/integrations/datafusion/README.md
index b9583726..1b6523e2 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -24,6 +24,18 @@
 
 This crate contains the integration of [Apache 
DataFusion](https://datafusion.apache.org/) and [Apache 
Paimon](https://paimon.apache.org/).
 
+## External engine snapshots
+
+Implement `TableEngineResolver::resolve_table_snapshot` to support
+`VERSION AS OF <snapshot_id>` on external data tables (integer IDs only).
+Providers must use the snapshot's data and schema. Missing snapshots are 
errors;
+`Ok(None)` means the table is missing. Resolved providers remain read-only.
+
+`SQLContext` needs no dialect setting and resolves snapshots independently of 
the
+current provider. Raw `SessionContext` needs `register_catalog_table_engine` 
and a
+dialect supporting `VERSION AS OF`; it preloads the current provider and 
propagates
+loading errors.
+
 ## Reader memory
 
 Paimon Parquet scans use the memory pool from the executing DataFusion 
`TaskContext`.
diff --git a/crates/integrations/datafusion/src/catalog.rs 
b/crates/integrations/datafusion/src/catalog.rs
index 9ba17d95..4232ae2b 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -49,9 +49,7 @@ pub(crate) type SessionStateProvider = Arc<dyn Fn() -> 
Option<SessionState> + Se
 /// providers, so registrations stay visible to schemas obtained earlier.
 type TableEngines = Arc<RwLock<HashMap<PaimonTableType, Arc<dyn 
TableEngineResolver>>>>;
 
-/// What an engine is asked to resolve. Non-exhaustive so later releases can
-/// carry more of the request — a snapshot selector, say — without breaking
-/// existing resolvers.
+/// Identifies an engine-owned table.
 #[derive(Debug, Clone)]
 #[non_exhaustive]
 pub struct EngineTableRequest {
@@ -71,10 +69,7 @@ impl EngineTableRequest {
     }
 }
 
-/// Resolves tables owned by another engine (see
-/// [`PaimonCatalogProvider::register_table_engine`]). `Ok(None)` means not
-/// found; errors propagate, so an engine failure never looks like a missing
-/// table.
+/// Resolves external tables. `Ok(None)` means missing; engine errors 
propagate.
 #[async_trait]
 pub trait TableEngineResolver: Debug + Send + Sync {
     /// Resolve a request to the engine's table provider.
@@ -83,6 +78,21 @@ pub trait TableEngineResolver: Debug + Send + Sync {
         request: &EngineTableRequest,
     ) -> DFResult<Option<Arc<dyn TableProvider>>>;
 
+    /// Resolve an exact snapshot and its schema.
+    /// Missing snapshots are errors; `Ok(None)` means the table is missing.
+    async fn resolve_table_snapshot(
+        &self,
+        request: &EngineTableRequest,
+        _snapshot_id: i64,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        Err(plan_datafusion_err!(
+            "time travel is not supported for routed '{}' tables ('{}.{}')",
+            request.declared,
+            request.database,
+            request.table
+        ))
+    }
+
     /// Resolves `<table>$<metadata_table>`; returns `Ok(None)` if missing or 
unsupported.
     /// The request names the base table; `metadata_table` is ASCII-lowercase.
     /// Session scan options are checked before this call.
@@ -95,14 +105,82 @@ pub trait TableEngineResolver: Debug + Send + Sync {
     }
 }
 
-/// Read-only wrapper around an engine-resolved provider: reads delegate,
-/// DML is rejected even when the engine's own provider is writable.
-#[derive(Debug)]
+/// Delegates reads to the engine and rejects DML.
+#[derive(Debug, Clone)]
 pub(crate) struct ReadOnlyTableProvider {
     inner: Arc<dyn TableProvider>,
     pub(crate) declared: PaimonTableType,
     pub(crate) table_name: String,
     metadata_table: bool,
+    request: EngineTableRequest,
+    resolver: Arc<dyn TableEngineResolver>,
+    dynamic_options: DynamicOptions,
+}
+
+impl ReadOnlyTableProvider {
+    pub(crate) fn unsupported_time_travel(&self) -> 
datafusion::error::DataFusionError {
+        plan_datafusion_err!(
+            "time travel is not supported for routed '{}' tables ('{}'); \
+             only VERSION AS OF with an integer snapshot id is supported on 
data tables",
+            self.declared,
+            self.table_name
+        )
+    }
+
+    pub(crate) async fn at_snapshot(&self, snapshot_id: i64) -> 
DFResult<Arc<dyn TableProvider>> {
+        if self.metadata_table {
+            return Err(self.unsupported_time_travel());
+        }
+        resolve_engine_snapshot(
+            self.request.clone(),
+            Arc::clone(&self.resolver),
+            Arc::clone(&self.dynamic_options),
+            snapshot_id,
+        )
+        .await
+    }
+}
+
+pub(crate) fn parse_engine_snapshot_id(version: &str) -> DFResult<i64> {
+    version.parse().map_err(|_| {
+        plan_datafusion_err!(
+            "external table time travel requires an integer snapshot id in 
VERSION AS OF, got '{version}'"
+        )
+    })
+}
+
+async fn resolve_engine_snapshot(
+    request: EngineTableRequest,
+    resolver: Arc<dyn TableEngineResolver>,
+    dynamic_options: DynamicOptions,
+    snapshot_id: i64,
+) -> DFResult<Arc<dyn TableProvider>> {
+    await_with_runtime(async move {
+        let table_name = format!("{}.{}", request.database, request.table);
+        let options = dynamic_options
+            .read()
+            .unwrap_or_else(|e| e.into_inner())
+            .clone();
+        paimon::spec::CoreOptions::new(&options)
+            .ensure_engine_can_serve(&table_name)
+            .map_err(to_datafusion_error)?;
+        let inner = resolver
+            .resolve_table_snapshot(&request, snapshot_id)
+            .await?
+            .ok_or_else(|| {
+                plan_datafusion_err!("table '{table_name}' was not found at 
snapshot {snapshot_id}")
+            })?;
+        Ok(Arc::new(ReadOnlyTableProvider {
+            inner,
+            declared: request.declared,
+            table_name,
+            metadata_table: false,
+            request,
+            resolver,
+            dynamic_options,
+        }) as Arc<dyn TableProvider>)
+    })
+    .await
 }
 
 #[async_trait]
@@ -170,20 +248,16 @@ impl TableProvider for ReadOnlyTableProvider {
     }
 }
 
-/// Metadata-only provider for an external table whose engine is not 
registered.
-///
-/// DataFusion's information schema asks every catalog table for its schema.
-/// Returning this provider keeps those metadata queries available without
-/// weakening the fail-closed behavior for actual table reads or writes.
+/// Exposes an unregistered engine's table schema while rejecting scans and 
writes.
 #[derive(Debug)]
-struct UnavailableEngineTableProvider {
+pub(crate) struct UnavailableEngineTableProvider {
     schema: datafusion::arrow::datatypes::SchemaRef,
     declared: PaimonTableType,
     table_name: String,
 }
 
 impl UnavailableEngineTableProvider {
-    fn unavailable_error(&self) -> datafusion::error::DataFusionError {
+    pub(crate) fn unavailable_error(&self) -> 
datafusion::error::DataFusionError {
         plan_datafusion_err!(
             "no table engine is registered for '{}' tables ('{}')",
             self.declared,
@@ -349,6 +423,34 @@ impl PaimonCatalogProvider {
         Arc::clone(&self.table_engines)
     }
 
+    pub(crate) async fn table_at_snapshot(
+        &self,
+        request: EngineTableRequest,
+        snapshot_id: i64,
+    ) -> DFResult<Arc<dyn TableProvider>> {
+        let resolver = self
+            .table_engines
+            .read()
+            .unwrap_or_else(|e| e.into_inner())
+            .get(&request.declared)
+            .cloned()
+            .ok_or_else(|| {
+                plan_datafusion_err!(
+                    "no table engine is registered for '{}' tables ('{}.{}')",
+                    request.declared,
+                    request.database,
+                    request.table
+                )
+            })?;
+        resolve_engine_snapshot(
+            request,
+            resolver,
+            Arc::clone(&self.dynamic_options),
+            snapshot_id,
+        )
+        .await
+    }
+
     fn paimon_schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
         let catalog = Arc::clone(&self.catalog);
         let table_engines = self.table_engines();
@@ -812,6 +914,9 @@ impl SchemaProvider for PaimonSchemaProvider {
                             declared,
                             table_name,
                             metadata_table,
+                            request,
+                            resolver: Arc::clone(resolver),
+                            dynamic_options,
                         }) as Arc<dyn TableProvider>
                     }))
                 }
diff --git a/crates/integrations/datafusion/src/relation_planner.rs 
b/crates/integrations/datafusion/src/relation_planner.rs
index d9cf0492..2078d48e 100644
--- a/crates/integrations/datafusion/src/relation_planner.rs
+++ b/crates/integrations/datafusion/src/relation_planner.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Custom [`RelationPlanner`] for Paimon time travel via `VERSION AS OF` and 
`TIMESTAMP AS OF`.
+//! Time-travel planning for Paimon tables and registered engines.
 
 use std::collections::HashMap;
 use std::fmt::Debug;
@@ -24,6 +24,7 @@ use std::sync::Arc;
 use datafusion::catalog::default_table_source::{provider_as_source, 
source_as_provider};
 use datafusion::common::plan_datafusion_err;
 use datafusion::common::TableReference;
+use datafusion::datasource::TableProvider;
 use datafusion::error::Result as DFResult;
 use datafusion::logical_expr::builder::LogicalPlanBuilder;
 use datafusion::logical_expr::planner::{
@@ -32,15 +33,12 @@ use datafusion::logical_expr::planner::{
 use datafusion::sql::sqlparser::ast::{self, TableFactor, TableVersion};
 use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
 
-use crate::catalog::ReadOnlyTableProvider;
+use crate::catalog::{
+    parse_engine_snapshot_id, ReadOnlyTableProvider, 
UnavailableEngineTableProvider,
+};
 use crate::table::{ObjectTableProvider, PaimonTableProvider};
 
-/// A [`RelationPlanner`] that intercepts `VERSION AS OF` and `TIMESTAMP AS OF`
-/// clauses on Paimon tables and resolves them to time travel options.
-///
-/// - `VERSION AS OF <integer or string>` → sets `scan.version` option on the 
table.
-///   At scan time, the version is resolved: tag name (if exists) → snapshot 
id → error.
-/// - `TIMESTAMP AS OF <timestamp string>` → parsed as a timestamp, sets 
`scan.timestamp-millis`.
+/// Resolves Paimon time travel and registered engine snapshot IDs.
 #[derive(Debug)]
 pub struct PaimonRelationPlanner;
 
@@ -62,7 +60,6 @@ impl RelationPlanner for PaimonRelationPlanner {
         relation: TableFactor,
         context: &mut dyn RelationPlannerContext,
     ) -> DFResult<RelationPlanning> {
-        // Only handle Table factors with a version clause.
         let TableFactor::Table {
             ref name,
             ref version,
@@ -76,27 +73,33 @@ impl RelationPlanner for PaimonRelationPlanner {
             return Ok(RelationPlanning::Original(Box::new(relation)));
         };
 
-        // Resolve the table reference.
         let table_ref = object_name_to_table_reference(name, context)?;
         let source = context
             .context_provider()
             .get_table_source(table_ref.clone())?;
         let provider = source_as_provider(&source)?;
 
-        // Every version clause, not just the two resolved below: falling
-        // through drops the clause silently.
+        if let Some(unavailable) = 
provider.downcast_ref::<UnavailableEngineTableProvider>() {
+            return Err(unavailable.unavailable_error());
+        }
+
         if let Some(routed) = provider.downcast_ref::<ReadOnlyTableProvider>() 
{
-            return Err(plan_datafusion_err!(
-                "time travel is not supported for routed '{}' tables ('{}')",
-                routed.declared,
-                routed.table_name
-            ));
+            let TableVersion::VersionAsOf(expr) = version else {
+                return Err(routed.unsupported_time_travel());
+            };
+            let options = resolve_version_as_of(expr)?;
+            let snapshot_id = 
parse_engine_snapshot_id(&options[SCAN_VERSION_OPTION])?;
+            let routed = routed.clone();
+            let new_provider = crate::runtime::block_on_with_runtime(
+                async move { routed.at_snapshot(snapshot_id).await },
+                "engine snapshot resolution thread panicked",
+            )?;
+            return plan_scan(relation, table_ref, new_provider);
         }
 
         let Some(paimon_provider) = 
provider.downcast_ref::<PaimonTableProvider>() else {
-            // Ours, but with no snapshot to rewrite onto: refuse, because
-            // handing them on reaches the default planner, which drops the
-            // clause. Any other provider is another engine's, clause and all.
+            // Reject unsupported Paimon providers before the default planner 
drops the clause.
+            // Leave foreign providers to their own planner.
             if provider.is::<ObjectTableProvider>()
                 || 
crate::system_tables::is_system_table_provider(provider.as_ref())
             {
@@ -109,12 +112,10 @@ impl RelationPlanner for PaimonRelationPlanner {
 
         let extra_options = match version {
             TableVersion::VersionAsOf(expr) => resolve_version_as_of(expr)?,
-            // Same timestamp expression as `TIMESTAMP AS OF`.
             TableVersion::TimestampAsOf(expr) | 
TableVersion::ForSystemTimeAsOf(expr) => {
                 resolve_timestamp_as_of(expr)?
             }
-            // `AT(...)` and `CHANGES(...)` name neither a snapshot nor a
-            // timestamp; dropping the clause would answer with current rows.
+            // Never let unsupported version clauses fall through to current 
data.
             _ => {
                 return Err(plan_datafusion_err!(
                     "this time-travel syntax is not supported for Paimon 
tables; \
@@ -123,9 +124,7 @@ impl RelationPlanner for PaimonRelationPlanner {
             }
         };
 
-        // Resolving time travel may switch the table to the snapshot's schema,
-        // which requires async IO; this planner hook is synchronous, so bridge
-        // through the shared runtime like other sync DataFusion callbacks.
+        // Snapshot schema resolution needs async I/O inside this synchronous 
hook.
         let table = paimon_provider.table().clone();
         let new_table = crate::runtime::block_on_with_runtime(
             async move { table.copy_with_time_travel(extra_options).await },
@@ -133,20 +132,24 @@ impl RelationPlanner for PaimonRelationPlanner {
         )
         .map_err(crate::to_datafusion_error)?;
         let new_provider = PaimonTableProvider::try_new(new_table)?;
-        let new_source = provider_as_source(Arc::new(new_provider));
-
-        // Destructure to get alias.
-        let TableFactor::Table { alias, .. } = relation else {
-            unreachable!()
-        };
-
-        let plan = LogicalPlanBuilder::scan(table_ref, new_source, 
None)?.build()?;
-        Ok(RelationPlanning::Planned(Box::new(PlannedRelation::new(
-            plan, alias,
-        ))))
+        plan_scan(relation, table_ref, Arc::new(new_provider))
     }
 }
 
+fn plan_scan(
+    relation: TableFactor,
+    table_ref: TableReference,
+    provider: Arc<dyn TableProvider>,
+) -> DFResult<RelationPlanning> {
+    let TableFactor::Table { alias, .. } = relation else {
+        unreachable!()
+    };
+    let plan = LogicalPlanBuilder::scan(table_ref, 
provider_as_source(provider), None)?.build()?;
+    Ok(RelationPlanning::Planned(Box::new(PlannedRelation::new(
+        plan, alias,
+    ))))
+}
+
 /// Convert a sqlparser `ObjectName` to a DataFusion `TableReference`.
 fn object_name_to_table_reference(
     name: &ast::ObjectName,
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index e22b4374..140a424f 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -55,7 +55,7 @@ use datafusion::arrow::compute::cast;
 use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
 use datafusion::arrow::record_batch::RecordBatch;
 use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
-use datafusion::common::TableReference;
+use datafusion::common::{plan_datafusion_err, TableReference};
 use datafusion::datasource::{MemTable, TableProvider};
 use datafusion::error::{DataFusionError, Result as DFResult};
 use datafusion::execution::context::SQLOptions;
@@ -86,7 +86,7 @@ use paimon::spec::{
 };
 
 use crate::error::to_datafusion_error;
-use crate::table_loader::load_table_for_read;
+use crate::table::PaimonTableProvider;
 use crate::{BlobReaderRegistry, DynamicOptions};
 
 /// A SQL context that supports registering multiple Paimon catalogs and 
executing SQL.
@@ -766,20 +766,13 @@ impl SQLContext {
         }
     }
 
-    /// Handle SQL queries containing time-travel syntax (`VERSION AS OF` / 
`TIMESTAMP AS OF`).
-    ///
-    /// DataFusion's default SQL parser does not support these clauses, so we:
-    /// 1. Extract all table name + version/timestamp pairs (skipping string 
literals and comments)
-    /// 2. Strip the time-travel clauses from the SQL
-    /// 3. For each table, create a `PaimonTableProvider` with the appropriate 
scan options
-    ///    (merged with session-scoped dynamic options)
-    /// 4. Register them as UUID-named temp tables, execute the rewritten SQL, 
then deregister
+    /// Rewrite time-travel references to temporary snapshot providers.
+    /// The tracker removes registrations after planning.
     async fn handle_time_travel_query(
         &self,
         sql: &str,
         sql_options: SQLOptions,
     ) -> DFResult<DataFrame> {
-        use crate::table::PaimonTableProvider;
         use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
 
         let mut tracker = crate::merge_into::TempTableTracker::new(self);
@@ -793,42 +786,21 @@ impl SQLContext {
             ));
         }
 
-        // Collect all replacements: (clause_range, uuid_name)
         let mut replacements: Vec<((usize, usize), String)> = Vec::new();
 
-        // Process all VERSION AS OF clauses
         for info in &version_clauses {
             let table_ref: datafusion::common::TableReference = 
info.table_name.as_str().into();
-            let (catalog, _catalog_name, identifier) =
+            let (catalog, catalog_name, identifier) =
                 self.resolve_table_name_from_ref(&table_ref)?;
-
-            let (paimon_table, base_identifier, system_name) =
-                load_table_for_read(&catalog, &identifier).await?;
-
-            // Merge dynamic options with time-travel options
-            let mut options = self.dynamic_options.read().unwrap().clone();
-            options.insert(SCAN_VERSION_OPTION.to_string(), 
info.version.clone());
-
-            let table_with_options = paimon_table
-                .copy_with_time_travel(options)
-                .await
-                .map_err(|e| DataFusionError::External(Box::new(e)))?;
-            let provider: Arc<dyn TableProvider> = if let Some(system_name) = 
system_name {
-                crate::system_tables::provider_for_table(
-                    Arc::clone(&catalog),
-                    base_identifier,
-                    table_with_options,
-                    &system_name,
-                )?
-                .ok_or_else(|| {
-                    DataFusionError::Plan(format!("Unknown Paimon system 
table: {system_name}"))
-                })?
-            } else {
-                
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
-                    table_with_options,
-                    self.blob_reader_registry.clone(),
-                )?)
-            };
+            let provider = self
+                .time_travel_provider(
+                    &catalog,
+                    &catalog_name,
+                    &identifier,
+                    SCAN_VERSION_OPTION,
+                    info.version.clone(),
+                )
+                .await?;
 
             let uuid_name = format!("__paimon_tt_{}", 
uuid::Uuid::new_v4().as_simple());
             self.register_temp_table(uuid_name.as_str(), provider)?;
@@ -836,41 +808,20 @@ impl SQLContext {
             replacements.push((info.clause_range, uuid_name));
         }
 
-        // Process all TIMESTAMP AS OF clauses
         for info in &timestamp_clauses {
             let table_ref: datafusion::common::TableReference = 
info.table_name.as_str().into();
-            let (catalog, _catalog_name, identifier) =
+            let (catalog, catalog_name, identifier) =
                 self.resolve_table_name_from_ref(&table_ref)?;
-
-            let (paimon_table, base_identifier, system_name) =
-                load_table_for_read(&catalog, &identifier).await?;
-
             let millis = Self::parse_timestamp_to_millis(&info.timestamp)?;
-
-            // Merge dynamic options with time-travel options
-            let mut options = self.dynamic_options.read().unwrap().clone();
-            options.insert(SCAN_TIMESTAMP_MILLIS_OPTION.to_string(), 
millis.to_string());
-
-            let table_with_options = paimon_table
-                .copy_with_time_travel(options)
-                .await
-                .map_err(|e| DataFusionError::External(Box::new(e)))?;
-            let provider: Arc<dyn TableProvider> = if let Some(system_name) = 
system_name {
-                crate::system_tables::provider_for_table(
-                    Arc::clone(&catalog),
-                    base_identifier,
-                    table_with_options,
-                    &system_name,
-                )?
-                .ok_or_else(|| {
-                    DataFusionError::Plan(format!("Unknown Paimon system 
table: {system_name}"))
-                })?
-            } else {
-                
Arc::new(PaimonTableProvider::try_new_with_blob_reader_registry(
-                    table_with_options,
-                    self.blob_reader_registry.clone(),
-                )?)
-            };
+            let provider = self
+                .time_travel_provider(
+                    &catalog,
+                    &catalog_name,
+                    &identifier,
+                    SCAN_TIMESTAMP_MILLIS_OPTION,
+                    millis.to_string(),
+                )
+                .await?;
 
             let uuid_name = format!("__paimon_tt_{}", 
uuid::Uuid::new_v4().as_simple());
             self.register_temp_table(uuid_name.as_str(), provider)?;
@@ -878,11 +829,9 @@ impl SQLContext {
             replacements.push((info.clause_range, uuid_name));
         }
 
-        // Sort replacements by position (descending) so that replacements
-        // from right to left don't shift indices of earlier ones
+        // Replace from right to left to preserve byte offsets.
         replacements.sort_by_key(|r| std::cmp::Reverse(r.0 .0));
 
-        // Build the rewritten SQL by replacing each clause from right to left
         let mut rewritten_sql = sql.to_string();
         for ((start, end), uuid_name) in &replacements {
             rewritten_sql = format!(
@@ -893,7 +842,6 @@ impl SQLContext {
             );
         }
 
-        // Execute the rewritten SQL; tracker auto-deregisters on drop.
         let current_catalog = self.current_catalog_name();
         let current_database = self
             .ctx
@@ -912,6 +860,89 @@ impl SQLContext {
         self.ctx.sql_with_options(&expanded, sql_options).await
     }
 
+    async fn time_travel_provider(
+        &self,
+        catalog: &Arc<dyn Catalog>,
+        catalog_name: &str,
+        identifier: &Identifier,
+        option: &str,
+        value: String,
+    ) -> DFResult<Arc<dyn TableProvider>> {
+        let parsed = identifier
+            .parsed_object_name()
+            .map_err(to_datafusion_error)?;
+        let base = Identifier::new(
+            identifier.database().to_string(),
+            parsed.table().to_string(),
+        );
+        let loaded = catalog
+            .load_table(&base)
+            .await
+            .map_err(to_datafusion_error)?;
+        if let paimon::catalog::LoadedTable::External(external) = &loaded {
+            if parsed.branch().is_some() {
+                return Err(plan_datafusion_err!(
+                    "branches are not supported for '{}' tables ('{}')",
+                    external.declared(),
+                    identifier.full_name()
+                ));
+            }
+            if parsed.system_table().is_some() || option != 
paimon::spec::SCAN_VERSION_OPTION {
+                return Err(plan_datafusion_err!(
+                    "time travel is not supported for routed '{}' tables 
('{}'); \
+                     only VERSION AS OF with an integer snapshot id is 
supported on data tables",
+                    external.declared(),
+                    identifier.full_name()
+                ));
+            }
+            let snapshot_id = 
crate::catalog::parse_engine_snapshot_id(&value)?;
+            let provider = self
+                .ctx
+                .catalog(catalog_name)
+                .ok_or_else(|| plan_datafusion_err!("Unknown catalog 
'{catalog_name}'"))?;
+            let provider = provider
+                .downcast_ref::<crate::catalog::PaimonCatalogProvider>()
+                .ok_or_else(|| {
+                    plan_datafusion_err!("Catalog '{catalog_name}' is not a 
Paimon catalog")
+                })?;
+            return provider
+                .table_at_snapshot(
+                    crate::EngineTableRequest::new(
+                        base.database().to_string(),
+                        base.object().to_string(),
+                        external.declared(),
+                    ),
+                    snapshot_id,
+                )
+                .await;
+        }
+
+        let (table, base, system_name) =
+            crate::table_loader::loaded_table_for_read(loaded, 
identifier).await?;
+        let mut options = self.dynamic_options.read().unwrap().clone();
+        options.insert(option.to_string(), value);
+        let table = table
+            .copy_with_time_travel(options)
+            .await
+            .map_err(to_datafusion_error)?;
+        if let Some(system_name) = system_name {
+            crate::system_tables::provider_for_table(
+                Arc::clone(catalog),
+                base,
+                table,
+                &system_name,
+            )?
+            .ok_or_else(|| plan_datafusion_err!("Unknown Paimon system table: 
{system_name}"))
+        } else {
+            Ok(Arc::new(
+                PaimonTableProvider::try_new_with_blob_reader_registry(
+                    table,
+                    self.blob_reader_registry.clone(),
+                )?,
+            ))
+        }
+    }
+
     /// Parse a timestamp string to milliseconds since epoch (using local 
timezone).
     fn parse_timestamp_to_millis(ts: &str) -> DFResult<i64> {
         use chrono::{Local, NaiveDateTime, TimeZone};
diff --git a/crates/integrations/datafusion/src/table_loader.rs 
b/crates/integrations/datafusion/src/table_loader.rs
index 664a58c0..748853a8 100644
--- a/crates/integrations/datafusion/src/table_loader.rs
+++ b/crates/integrations/datafusion/src/table_loader.rs
@@ -18,7 +18,7 @@
 use std::sync::Arc;
 
 use datafusion::error::{DataFusionError, Result as DFResult};
-use paimon::catalog::{Catalog, Identifier};
+use paimon::catalog::{Catalog, Identifier, LoadedTable};
 use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
@@ -58,6 +58,24 @@ pub(crate) fn ensure_paimon_served(table: &Table, 
identifier: &Identifier) -> DF
 pub(crate) async fn load_table_for_read(
     catalog: &Arc<dyn Catalog>,
     identifier: &Identifier,
+) -> DFResult<(Table, Identifier, Option<String>)> {
+    let parsed = identifier
+        .parsed_object_name()
+        .map_err(to_datafusion_error)?;
+    let base = Identifier::new(
+        identifier.database().to_string(),
+        parsed.table().to_string(),
+    );
+    let loaded = catalog
+        .load_table(&base)
+        .await
+        .map_err(to_datafusion_error)?;
+    loaded_table_for_read(loaded, identifier).await
+}
+
+pub(crate) async fn loaded_table_for_read(
+    loaded: LoadedTable,
+    identifier: &Identifier,
 ) -> DFResult<(Table, Identifier, Option<String>)> {
     let parsed = identifier
         .parsed_object_name()
@@ -66,32 +84,29 @@ pub(crate) async fn load_table_for_read(
         identifier.database().to_string(),
         parsed.table().to_string(),
     );
-    let mut table = match catalog.load_table(&base_identifier).await {
-        Ok(paimon::catalog::LoadedTable::Paimon(table)) => *table,
-        Ok(paimon::catalog::LoadedTable::Object(_)) => {
-            // The search UDTFs land here too, so the message names the table
-            // rather than what the caller asked of it.
+    let mut table = match loaded {
+        LoadedTable::Paimon(table) => *table,
+        LoadedTable::Object(_) => {
+            // Shared with search UDTFs, so errors name the base table.
             return Err(DataFusionError::Plan(format!(
                 "table '{}' is declared 'object-table' and cannot be read as a 
Paimon table",
                 base_identifier.full_name()
             )));
         }
-        Ok(paimon::catalog::LoadedTable::External(external)) => {
+        LoadedTable::External(external) => {
             return Err(DataFusionError::Plan(format!(
                 "table '{}' is declared '{}' and cannot be read as a Paimon 
table",
                 base_identifier.full_name(),
                 external.declared()
             )));
         }
-        // `LoadedTable` is non_exhaustive: a variant added upstream is not a
-        // Paimon table until this path says how to read one.
-        Ok(_) => {
+        // Reject unknown variants of non-exhaustive LoadedTable.
+        _ => {
             return Err(DataFusionError::Plan(format!(
                 "table '{}' cannot be read as a Paimon table",
                 base_identifier.full_name()
             )));
         }
-        Err(err) => return Err(to_datafusion_error(err)),
     };
     let system_table = parsed.system_table().map(str::to_string);
     if let Some(branch) = parsed.branch() {
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs 
b/crates/integrations/datafusion/tests/table_type_routing.rs
index d29a3938..243140aa 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -306,6 +306,81 @@ struct MetadataEngineResolver {
     calls: AtomicUsize,
 }
 
+#[derive(Debug, Default)]
+struct SnapshotEngineResolver {
+    calls: AtomicUsize,
+    current_calls: AtomicUsize,
+    current_unavailable: bool,
+}
+
+const LARGE_SNAPSHOT_ID: i64 = 6_251_097_304_546_047_273;
+
+#[async_trait]
+impl TableEngineResolver for SnapshotEngineResolver {
+    async fn resolve_table(
+        &self,
+        request: &EngineTableRequest,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        self.current_calls.fetch_add(1, Ordering::Relaxed);
+        if self.current_unavailable {
+            return Err(DataFusionError::Execution(
+                "current table is unavailable".into(),
+            ));
+        }
+        FakeEngineResolver.resolve_table(request).await
+    }
+
+    async fn resolve_metadata_table(
+        &self,
+        request: &EngineTableRequest,
+        metadata_table: &str,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        MetadataEngineResolver::default()
+            .resolve_metadata_table(request, metadata_table)
+            .await
+    }
+
+    async fn resolve_table_snapshot(
+        &self,
+        request: &EngineTableRequest,
+        snapshot_id: i64,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        self.calls.fetch_add(1, Ordering::Relaxed);
+        assert_eq!(request.database, DB);
+        assert_eq!(request.declared, TableType::IcebergTable);
+        tokio::time::sleep(std::time::Duration::from_millis(1)).await;
+        if request.table != "it" {
+            return Ok(None);
+        }
+        let (ids, values) = match snapshot_id {
+            10 => (vec![1, 2], vec!["old-a", "old-b"]),
+            20 => (vec![1, 4], vec!["new-a", "new-d"]),
+            LARGE_SNAPSHOT_ID => (vec![9], vec!["large-id"]),
+            500 => return Err(DataFusionError::Execution("snapshot backend 
failed".into())),
+            _ => {
+                return Err(DataFusionError::Plan(format!(
+                    "snapshot {snapshot_id} does not exist or has expired"
+                )));
+            }
+        };
+        let schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, false),
+            Field::new("historical", DataType::Utf8, false),
+        ]));
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(Int32Array::from(ids)),
+                Arc::new(StringArray::from(values)),
+            ],
+        )?;
+        Ok(Some(Arc::new(MemTable::try_new(
+            schema,
+            vec![vec![batch]],
+        )?)))
+    }
+}
+
 #[async_trait]
 impl TableEngineResolver for MetadataEngineResolver {
     async fn resolve_table(
@@ -418,6 +493,488 @@ fn column_i32(batches: &[RecordBatch]) -> Vec<i32> {
         .collect()
 }
 
+async fn enable_version_syntax(env: &TestEnv) {
+    env.ctx
+        .ctx()
+        .sql("SET datafusion.sql_parser.dialect = 'databricks'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+}
+
+async fn snapshot_env() -> (TestEnv, Arc<SnapshotEngineResolver>) {
+    let resolver = Arc::new(SnapshotEngineResolver::default());
+    let env = setup_with_engine(Some(resolver.clone())).await;
+    enable_version_syntax(&env).await;
+    (env, resolver)
+}
+
+async fn routed_sql(
+    env: &TestEnv,
+    native: bool,
+    sql: &str,
+) -> DFResult<datafusion::dataframe::DataFrame> {
+    if native {
+        env.ctx.ctx().sql(sql).await
+    } else {
+        env.ctx.sql(sql).await
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_uses_historical_data_and_schema() {
+    let (env, _) = snapshot_env().await;
+    for native in [false, true] {
+        let batches = routed_sql(
+            &env,
+            native,
+            &format!(
+                "SELECT h.historical FROM {CATALOG}.{DB}.it VERSION AS OF 10 
AS h WHERE h.id = 2"
+            ),
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+        assert_eq!(string_column_values(&batches, "historical"), ["old-b"]);
+
+        let err = routed_sql(
+            &env,
+            native,
+            &format!("SELECT payload FROM {CATALOG}.{DB}.it VERSION AS OF 10"),
+        )
+        .await
+        .unwrap_err();
+        assert!(err.to_string().contains("payload"), "{err}");
+
+        let batches = routed_sql(
+            &env,
+            native,
+            &format!("SELECT payload FROM {CATALOG}.{DB}.it ORDER BY id"),
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+        assert_eq!(string_column_values(&batches, "payload"), ["x", "y"]);
+
+        let mut versions = vec![LARGE_SNAPSHOT_ID.to_string()];
+        if !native {
+            versions.push(format!("'{LARGE_SNAPSHOT_ID}'"));
+        }
+        for version in versions {
+            let batches = routed_sql(
+                &env,
+                native,
+                &format!("SELECT historical FROM {CATALOG}.{DB}.it VERSION AS 
OF {version}"),
+            )
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+            assert_eq!(string_column_values(&batches, "historical"), 
["large-id"]);
+        }
+    }
+}
+
+#[tokio::test]
+async fn sql_context_snapshot_remains_readable_when_current_resolution_fails() 
{
+    let (env, resolver) = current_unavailable_env().await;
+    let batches = env
+        .ctx
+        .sql(&format!(
+            "SELECT h.historical FROM {CATALOG}.{DB}.it VERSION AS OF 10 AS h 
WHERE h.id = 2"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(string_column_values(&batches, "historical"), ["old-b"]);
+
+    let err = env
+        .ctx
+        .sql(&format!(
+            "SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 404 WHERE FALSE"
+        ))
+        .await
+        .unwrap_err();
+    assert!(err.to_string().contains("snapshot 404"), "{err}");
+    assert_eq!(resolver.current_calls.load(Ordering::Relaxed), 0);
+
+    for native in [false, true] {
+        for query in [
+            format!("SELECT * FROM {CATALOG}.{DB}.it"),
+            format!("SELECT * FROM {CATALOG}.{DB}.it WHERE FALSE"),
+            format!("SELECT COUNT(*) FROM {CATALOG}.{DB}.it LIMIT 0"),
+            format!(
+                "SELECT h.historical FROM {CATALOG}.{DB}.it VERSION AS OF 10 
AS h \
+                 JOIN {CATALOG}.{DB}.it AS c ON h.id = c.id"
+            ),
+        ] {
+            let err = routed_sql(&env, native, &query).await.unwrap_err();
+            assert!(
+                err.to_string().contains("current table is unavailable"),
+                "{err}"
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn raw_session_snapshot_propagates_current_resolution_errors() {
+    let (env, resolver) = current_unavailable_env().await;
+    for suffix in ["", " WHERE FALSE", " LIMIT 0"] {
+        let err = env
+            .ctx
+            .ctx()
+            .sql(&format!(
+                "SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 10{suffix}"
+            ))
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("current table is unavailable"),
+            "{err}"
+        );
+    }
+    assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+}
+
+async fn current_unavailable_env() -> (TestEnv, Arc<SnapshotEngineResolver>) {
+    let resolver = Arc::new(SnapshotEngineResolver {
+        current_unavailable: true,
+        ..Default::default()
+    });
+    let env = setup_with_engine(Some(resolver.clone())).await;
+    enable_version_syntax(&env).await;
+    (env, resolver)
+}
+
+#[tokio::test]
+async fn engine_errors_propagate_to_schema_table() {
+    let (env, _) = current_unavailable_env().await;
+    let schema = env.ctx.ctx().catalog(CATALOG).unwrap().schema(DB).unwrap();
+    let err = schema.table("it").await.map(|_| ()).unwrap_err();
+    assert!(
+        err.to_string().contains("current table is unavailable"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn engine_errors_propagate_to_context_table_provider() {
+    let (env, _) = current_unavailable_env().await;
+    let err = env
+        .ctx
+        .ctx()
+        .table_provider(format!("{CATALOG}.{DB}.it"))
+        .await
+        .map(|_| ())
+        .unwrap_err();
+    assert!(
+        err.to_string().contains("current table is unavailable"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn engine_errors_propagate_to_context_table() {
+    let (env, _) = current_unavailable_env().await;
+    let err = env
+        .ctx
+        .ctx()
+        .table(format!("{CATALOG}.{DB}.it"))
+        .await
+        .map(|_| ())
+        .unwrap_err();
+    assert!(
+        err.to_string().contains("current table is unavailable"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn engine_snapshots_can_be_joined_with_each_other_and_paimon() {
+    let (env, _) = snapshot_env().await;
+    for native in [false, true] {
+        let batches = routed_sql(
+            &env,
+            native,
+            &format!(
+                "SELECT a.historical AS old_value, b.historical AS new_value \
+             FROM {CATALOG}.{DB}.it VERSION AS OF 10 AS a \
+             JOIN {CATALOG}.{DB}.it VERSION AS OF 20 AS b ON a.id = b.id"
+            ),
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+        assert_batches_eq!(
+            [
+                "+-----------+-----------+",
+                "| old_value | new_value |",
+                "+-----------+-----------+",
+                "| old-a     | new-a     |",
+                "+-----------+-----------+",
+            ],
+            &batches
+        );
+
+        let batches = routed_sql(
+            &env,
+            native,
+            &format!(
+                "SELECT h.historical FROM {CATALOG}.{DB}.it VERSION AS OF 10 
AS h \
+             JOIN {CATALOG}.{DB}.pt VERSION AS OF 1 AS p ON h.id = p.id"
+            ),
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+        assert_eq!(string_column_values(&batches, "historical"), ["old-a"]);
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_supports_count_and_empty_results() {
+    let (env, _) = snapshot_env().await;
+    for native in [false, true] {
+        let batches = routed_sql(
+            &env,
+            native,
+            &format!("SELECT COUNT(*) AS n FROM {CATALOG}.{DB}.it VERSION AS 
OF 10"),
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+        assert_batches_eq!(["+---+", "| n |", "+---+", "| 2 |", "+---+"], 
&batches);
+
+        let df = routed_sql(
+            &env,
+            native,
+            &format!("SELECT historical FROM {CATALOG}.{DB}.it VERSION AS OF 
10 WHERE FALSE"),
+        )
+        .await
+        .unwrap();
+        assert_eq!(df.schema().field(0).name(), "historical");
+        assert_eq!(
+            df.collect()
+                .await
+                .unwrap()
+                .iter()
+                .map(RecordBatch::num_rows)
+                .sum::<usize>(),
+            0
+        );
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_errors_never_fall_back_to_current_data() {
+    let (env, _) = snapshot_env().await;
+    for native in [false, true] {
+        for (table, version, message) in [
+            ("it", 404, "snapshot 404 does not exist or has expired"),
+            ("it", 500, "snapshot backend failed"),
+            ("ghost", 10, "not found"),
+        ] {
+            let err = routed_sql(
+                &env,
+                native,
+                &format!(
+                    "SELECT * FROM {CATALOG}.{DB}.{table} VERSION AS OF 
{version} WHERE FALSE"
+                ),
+            )
+            .await
+            .unwrap_err();
+            assert!(err.to_string().contains(message), "{err}");
+        }
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_providers_remain_read_only() {
+    use datafusion::catalog::default_table_source::source_as_provider;
+    use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
+    use datafusion::logical_expr::{dml::InsertOp, LogicalPlan};
+    use datafusion::physical_plan::empty::EmptyExec;
+
+    let (env, _) = snapshot_env().await;
+    for native in [false, true] {
+        let df = routed_sql(
+            &env,
+            native,
+            &format!("SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 10"),
+        )
+        .await
+        .unwrap();
+        let mut provider = None;
+        df.logical_plan()
+            .apply(|node| {
+                if let LogicalPlan::TableScan(scan) = node {
+                    provider = Some(source_as_provider(&scan.source)?);
+                    return Ok(TreeNodeRecursion::Stop);
+                }
+                Ok(TreeNodeRecursion::Continue)
+            })
+            .unwrap();
+        let provider = provider.unwrap();
+        let err = provider
+            .insert_into(
+                &env.ctx.ctx().state(),
+                Arc::new(EmptyExec::new(provider.schema())),
+                InsertOp::Append,
+            )
+            .await
+            .unwrap_err();
+        assert!(err.to_string().contains("write is not supported"), "{err}");
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_resolution_checks_session_options() {
+    let (env, resolver) = snapshot_env().await;
+    for (key, value, message) in [
+        ("query-auth.enabled", "true", "query-auth"),
+        ("scan.version", "20", "time travel"),
+        ("incremental-between", "1,5", "incremental-between"),
+    ] {
+        env.ctx
+            .sql(&format!("SET 'paimon.{key}' = '{value}'"))
+            .await
+            .unwrap();
+        for native in [false, true] {
+            let err = routed_sql(
+                &env,
+                native,
+                &format!("SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 10"),
+            )
+            .await
+            .unwrap_err();
+            assert!(err.to_string().contains(message), "{err}");
+            assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+        }
+        env.ctx.sql(&format!("RESET 'paimon.{key}'")).await.unwrap();
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_rejects_unsupported_selectors_and_metadata_tables() {
+    let (env, resolver) = snapshot_env().await;
+    for native in [false, true] {
+        let (selector, message) = if native {
+            (
+                "FOR SYSTEM_TIME AS OF '2020-01-01 00:00:00'",
+                "time travel is not supported",
+            )
+        } else {
+            ("VERSION AS OF 'tag-name'", "integer snapshot id")
+        };
+        for (table, selector, message) in [
+            ("it", selector, message),
+            (
+                "it",
+                "VERSION AS OF 9223372036854775808",
+                "integer snapshot id",
+            ),
+            (
+                "it",
+                "TIMESTAMP AS OF '2020-01-01 00:00:00'",
+                "time travel is not supported",
+            ),
+            (
+                "it$history",
+                "VERSION AS OF 10",
+                "time travel is not supported",
+            ),
+            (
+                "it$branch_b1",
+                "VERSION AS OF 10",
+                "branches are not supported",
+            ),
+        ] {
+            let err = routed_sql(
+                &env,
+                native,
+                &format!("SELECT * FROM {CATALOG}.{DB}.\"{table}\" 
{selector}"),
+            )
+            .await
+            .unwrap_err();
+            assert!(err.to_string().contains(message), "{selector}: {err}");
+            assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+        }
+    }
+}
+
+#[tokio::test]
+async fn engine_snapshot_requires_explicit_resolver_support() {
+    for registered in [false, true] {
+        let resolver =
+            registered.then(|| Arc::new(FakeEngineResolver) as Arc<dyn 
TableEngineResolver>);
+        let env = setup_with_engine(resolver).await;
+        enable_version_syntax(&env).await;
+        for native in [false, true] {
+            let err = routed_sql(
+                &env,
+                native,
+                &format!("SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 10 
WHERE FALSE"),
+            )
+            .await
+            .unwrap_err();
+            let expected = if registered {
+                "time travel is not supported"
+            } else {
+                "no table engine is registered"
+            };
+            assert!(err.to_string().contains(expected), "{err}");
+        }
+    }
+}
+
+#[test]
+fn engine_snapshot_resolution_on_runtime_workers() {
+    let runtime = paimon_datafusion::runtime::runtime();
+    runtime.block_on(async {
+        let (env, _) = snapshot_env().await;
+        tokio::time::timeout(
+            std::time::Duration::from_secs(20),
+            tokio::spawn(async move {
+                let batches = routed_sql(
+                    &env,
+                    true,
+                    &format!(
+                        "SELECT historical FROM {CATALOG}.{DB}.it VERSION AS 
OF 10 ORDER BY id"
+                    ),
+                )
+                .await
+                .unwrap()
+                .collect()
+                .await
+                .unwrap();
+                assert_eq!(
+                    string_column_values(&batches, "historical"),
+                    ["old-a", "old-b"]
+                );
+            }),
+        )
+        .await
+        .expect("snapshot resolution stalled on a runtime worker")
+        .unwrap();
+    });
+}
+
 #[tokio::test]
 async fn paimon_path_still_serves_paimon_tables() {
     let env = setup().await;
@@ -1385,6 +1942,27 @@ async fn 
registering_on_a_raw_session_installs_the_planner() {
         panic!("registration must install the planner, so this cannot read 
current data");
     };
     assert!(err.to_string().contains("not supported"), "{err}");
+
+    register_catalog_table_engine(
+        &ctx,
+        CATALOG,
+        TableType::IcebergTable,
+        Arc::new(SnapshotEngineResolver::default()),
+    )
+    .unwrap();
+    let batches = ctx
+        .sql(&format!(
+            "SELECT historical FROM {CATALOG}.{DB}.it VERSION AS OF 10 ORDER 
BY id"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(
+        string_column_values(&batches, "historical"),
+        ["old-a", "old-b"]
+    );
 }
 
 #[tokio::test]

Reply via email to