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 2104a029 feat(datafusion): support timestamp time travel for external
engines (#1121)
2104a029 is described below
commit 2104a02922dca25a7bdf5e1fb1a1f3d09eab62ee
Author: Jiajia Li <[email protected]>
AuthorDate: Wed Oct 14 08:32:45 2026 +0800
feat(datafusion): support timestamp time travel for external engines (#1121)
---
crates/integrations/datafusion/README.md | 7 +-
crates/integrations/datafusion/src/catalog.rs | 93 ++++--
.../datafusion/src/relation_planner.rs | 39 ++-
crates/integrations/datafusion/src/sql_context.rs | 47 ++-
.../datafusion/tests/table_type_routing.rs | 345 ++++++++++++++++-----
5 files changed, 391 insertions(+), 140 deletions(-)
diff --git a/crates/integrations/datafusion/README.md
b/crates/integrations/datafusion/README.md
index 1b6523e2..5c7904b9 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -27,13 +27,16 @@ This crate contains the integration of [Apache
DataFusion](https://datafusion.ap
## External engine snapshots
Implement `TableEngineResolver::resolve_table_snapshot` to support
-`VERSION AS OF <snapshot_id>` on external data tables (integer IDs only).
+`VERSION AS OF <snapshot_id>` on external data tables (integer IDs only), and
+`resolve_table_at_timestamp` to support `TIMESTAMP AS OF '<yyyy-MM-dd
HH:mm:ss>'`.
+The timestamp is read in the local time zone and passed as epoch milliseconds;
+the engine picks the snapshot.
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
+dialect supporting these clauses; it preloads the current provider and
propagates
loading errors.
## Reader memory
diff --git a/crates/integrations/datafusion/src/catalog.rs
b/crates/integrations/datafusion/src/catalog.rs
index 4232ae2b..19770788 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -93,6 +93,22 @@ pub trait TableEngineResolver: Debug + Send + Sync {
))
}
+ /// Resolve the table as of `timestamp_millis` (epoch milliseconds) and its
+ /// schema; the engine picks the snapshot. A time with no snapshot is an
error;
+ /// `Ok(None)` means the table is missing.
+ async fn resolve_table_at_timestamp(
+ &self,
+ request: &EngineTableRequest,
+ _timestamp_millis: i64,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ Err(plan_datafusion_err!(
+ "timestamp 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.
@@ -117,25 +133,50 @@ pub(crate) struct ReadOnlyTableProvider {
dynamic_options: DynamicOptions,
}
+/// The historical state requested from an engine.
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum EngineTimeTravel {
+ Snapshot(i64),
+ TimestampMillis(i64),
+}
+
+impl std::fmt::Display for EngineTimeTravel {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Snapshot(id) => write!(f, "snapshot {id}"),
+ Self::TimestampMillis(millis) => write!(f, "timestamp-millis
{millis}"),
+ }
+ }
+}
+
+pub(crate) fn unsupported_engine_time_travel(
+ declared: PaimonTableType,
+ table_name: &str,
+) -> datafusion::error::DataFusionError {
+ plan_datafusion_err!(
+ "time travel is not supported for routed '{declared}' tables
('{table_name}'); \
+ only VERSION AS OF with an integer snapshot id and TIMESTAMP AS OF \
+ are supported on data tables"
+ )
+}
+
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
- )
+ unsupported_engine_time_travel(self.declared, &self.table_name)
}
- pub(crate) async fn at_snapshot(&self, snapshot_id: i64) ->
DFResult<Arc<dyn TableProvider>> {
+ pub(crate) async fn at_time_travel(
+ &self,
+ travel: EngineTimeTravel,
+ ) -> DFResult<Arc<dyn TableProvider>> {
if self.metadata_table {
return Err(self.unsupported_time_travel());
}
- resolve_engine_snapshot(
+ resolve_engine_time_travel(
self.request.clone(),
Arc::clone(&self.resolver),
Arc::clone(&self.dynamic_options),
- snapshot_id,
+ travel,
)
.await
}
@@ -149,11 +190,11 @@ pub(crate) fn parse_engine_snapshot_id(version: &str) ->
DFResult<i64> {
})
}
-async fn resolve_engine_snapshot(
+async fn resolve_engine_time_travel(
request: EngineTableRequest,
resolver: Arc<dyn TableEngineResolver>,
dynamic_options: DynamicOptions,
- snapshot_id: i64,
+ travel: EngineTimeTravel,
) -> DFResult<Arc<dyn TableProvider>> {
await_with_runtime(async move {
let table_name = format!("{}.{}", request.database, request.table);
@@ -164,12 +205,17 @@ async fn resolve_engine_snapshot(
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}")
- })?;
+ let resolved = match travel {
+ EngineTimeTravel::Snapshot(id) =>
resolver.resolve_table_snapshot(&request, id).await?,
+ EngineTimeTravel::TimestampMillis(millis) => {
+ resolver
+ .resolve_table_at_timestamp(&request, millis)
+ .await?
+ }
+ };
+ let inner = resolved.ok_or_else(|| {
+ plan_datafusion_err!("table '{table_name}' was not found at
{travel}")
+ })?;
Ok(Arc::new(ReadOnlyTableProvider {
inner,
declared: request.declared,
@@ -423,10 +469,10 @@ impl PaimonCatalogProvider {
Arc::clone(&self.table_engines)
}
- pub(crate) async fn table_at_snapshot(
+ pub(crate) async fn table_at_time_travel(
&self,
request: EngineTableRequest,
- snapshot_id: i64,
+ travel: EngineTimeTravel,
) -> DFResult<Arc<dyn TableProvider>> {
let resolver = self
.table_engines
@@ -442,13 +488,8 @@ impl PaimonCatalogProvider {
request.table
)
})?;
- resolve_engine_snapshot(
- request,
- resolver,
- Arc::clone(&self.dynamic_options),
- snapshot_id,
- )
- .await
+ resolve_engine_time_travel(request, resolver,
Arc::clone(&self.dynamic_options), travel)
+ .await
}
fn paimon_schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
diff --git a/crates/integrations/datafusion/src/relation_planner.rs
b/crates/integrations/datafusion/src/relation_planner.rs
index 2078d48e..10b2679d 100644
--- a/crates/integrations/datafusion/src/relation_planner.rs
+++ b/crates/integrations/datafusion/src/relation_planner.rs
@@ -34,11 +34,12 @@ use datafusion::sql::sqlparser::ast::{self, TableFactor,
TableVersion};
use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
use crate::catalog::{
- parse_engine_snapshot_id, ReadOnlyTableProvider,
UnavailableEngineTableProvider,
+ parse_engine_snapshot_id, EngineTimeTravel, ReadOnlyTableProvider,
+ UnavailableEngineTableProvider,
};
use crate::table::{ObjectTableProvider, PaimonTableProvider};
-/// Resolves Paimon time travel and registered engine snapshot IDs.
+/// Resolves Paimon time travel, and registered engine snapshot IDs and
timestamps.
#[derive(Debug)]
pub struct PaimonRelationPlanner;
@@ -84,15 +85,22 @@ impl RelationPlanner for PaimonRelationPlanner {
}
if let Some(routed) = provider.downcast_ref::<ReadOnlyTableProvider>()
{
- let TableVersion::VersionAsOf(expr) = version else {
- return Err(routed.unsupported_time_travel());
+ let travel = match version {
+ TableVersion::VersionAsOf(expr) => {
+ let options = resolve_version_as_of(expr)?;
+ EngineTimeTravel::Snapshot(parse_engine_snapshot_id(
+ &options[SCAN_VERSION_OPTION],
+ )?)
+ }
+ TableVersion::TimestampAsOf(expr) => {
+
EngineTimeTravel::TimestampMillis(timestamp_as_of_millis(expr)?)
+ }
+ _ => 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",
+ async move { routed.at_time_travel(travel).await },
+ "engine time travel resolution thread panicked",
)?;
return plan_scan(relation, table_ref, new_provider);
}
@@ -210,14 +218,19 @@ fn resolve_version_as_of(expr: &ast::Expr) ->
DFResult<HashMap<String, String>>
/// Resolve `TIMESTAMP AS OF <expr>` into `scan.timestamp-millis` option.
fn resolve_timestamp_as_of(expr: &ast::Expr) -> DFResult<HashMap<String,
String>> {
+ let millis = timestamp_as_of_millis(expr)?;
+ Ok(HashMap::from([(
+ SCAN_TIMESTAMP_MILLIS_OPTION.to_string(),
+ millis.to_string(),
+ )]))
+}
+
+/// Parse the timestamp string of `TIMESTAMP AS OF <expr>` to epoch millis.
+fn timestamp_as_of_millis(expr: &ast::Expr) -> DFResult<i64> {
match expr {
ast::Expr::Value(v) => match &v.value {
ast::Value::SingleQuotedString(s) |
ast::Value::DoubleQuotedString(s) => {
- let millis = parse_timestamp_to_millis(s)?;
- Ok(HashMap::from([(
- SCAN_TIMESTAMP_MILLIS_OPTION.to_string(),
- millis.to_string(),
- )]))
+ parse_timestamp_to_millis(s)
}
_ => Err(datafusion::error::DataFusionError::Plan(format!(
"Unsupported TIMESTAMP AS OF expression: {expr}. Expected a
timestamp string."
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index 140a424f..f4c02dc1 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -773,8 +773,6 @@ impl SQLContext {
sql: &str,
sql_options: SQLOptions,
) -> DFResult<DataFrame> {
- use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
-
let mut tracker = crate::merge_into::TempTableTracker::new(self);
let version_clauses = extract_all_version_as_of(sql);
@@ -797,8 +795,7 @@ impl SQLContext {
&catalog,
&catalog_name,
&identifier,
- SCAN_VERSION_OPTION,
- info.version.clone(),
+ TimeTravelClause::Version(info.version.clone()),
)
.await?;
@@ -818,8 +815,7 @@ impl SQLContext {
&catalog,
&catalog_name,
&identifier,
- SCAN_TIMESTAMP_MILLIS_OPTION,
- millis.to_string(),
+ TimeTravelClause::TimestampMillis(millis),
)
.await?;
@@ -865,9 +861,11 @@ impl SQLContext {
catalog: &Arc<dyn Catalog>,
catalog_name: &str,
identifier: &Identifier,
- option: &str,
- value: String,
+ clause: TimeTravelClause,
) -> DFResult<Arc<dyn TableProvider>> {
+ use crate::catalog::EngineTimeTravel;
+ use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
+
let parsed = identifier
.parsed_object_name()
.map_err(to_datafusion_error)?;
@@ -887,15 +885,20 @@ impl SQLContext {
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",
+ if parsed.system_table().is_some() {
+ return Err(crate::catalog::unsupported_engine_time_travel(
external.declared(),
- identifier.full_name()
+ &identifier.full_name(),
));
}
- let snapshot_id =
crate::catalog::parse_engine_snapshot_id(&value)?;
+ let travel = match clause {
+ TimeTravelClause::Version(version) => {
+
EngineTimeTravel::Snapshot(crate::catalog::parse_engine_snapshot_id(&version)?)
+ }
+ TimeTravelClause::TimestampMillis(millis) => {
+ EngineTimeTravel::TimestampMillis(millis)
+ }
+ };
let provider = self
.ctx
.catalog(catalog_name)
@@ -906,19 +909,25 @@ impl SQLContext {
plan_datafusion_err!("Catalog '{catalog_name}' is not a
Paimon catalog")
})?;
return provider
- .table_at_snapshot(
+ .table_at_time_travel(
crate::EngineTableRequest::new(
base.database().to_string(),
base.object().to_string(),
external.declared(),
),
- snapshot_id,
+ travel,
)
.await;
}
let (table, base, system_name) =
crate::table_loader::loaded_table_for_read(loaded,
identifier).await?;
+ let (option, value) = match clause {
+ TimeTravelClause::Version(version) => (SCAN_VERSION_OPTION,
version),
+ TimeTravelClause::TimestampMillis(millis) => {
+ (SCAN_TIMESTAMP_MILLIS_OPTION, millis.to_string())
+ }
+ };
let mut options = self.dynamic_options.read().unwrap().clone();
options.insert(option.to_string(), value);
let table = table
@@ -3584,6 +3593,12 @@ struct TimestampAsOfInfo {
clause_range: (usize, usize),
}
+/// A parsed `VERSION AS OF` or `TIMESTAMP AS OF` selector.
+enum TimeTravelClause {
+ Version(String),
+ TimestampMillis(i64),
+}
+
/// Check whether a SQL string contains a time-travel keyword (`VERSION AS OF`
or
/// `TIMESTAMP AS OF`) **outside** of single-quoted string literals, `--` line
/// comments, and `/* */` block comments.
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs
b/crates/integrations/datafusion/tests/table_type_routing.rs
index 243140aa..9e291430 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -17,7 +17,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use datafusion::arrow::array::{Array, Int32Array, StringArray};
@@ -311,9 +311,24 @@ struct SnapshotEngineResolver {
calls: AtomicUsize,
current_calls: AtomicUsize,
current_unavailable: bool,
+ timestamps: Mutex<Vec<i64>>,
}
const LARGE_SNAPSHOT_ID: i64 = 6_251_097_304_546_047_273;
+/// Local commit times of the fake engine's snapshots 10 and 20.
+const SNAPSHOT_10_TIME: &str = "2026-10-01 00:00:00";
+const SNAPSHOT_20_TIME: &str = "2026-10-02 00:00:00";
+const BACKEND_FAILURE_TIME: &str = "2000-01-01 00:00:00";
+
+fn local_millis(timestamp: &str) -> i64 {
+ use chrono::TimeZone;
+ let naive = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d
%H:%M:%S").unwrap();
+ chrono::Local
+ .from_local_datetime(&naive)
+ .single()
+ .unwrap()
+ .timestamp_millis()
+}
#[async_trait]
impl TableEngineResolver for SnapshotEngineResolver {
@@ -379,6 +394,27 @@ impl TableEngineResolver for SnapshotEngineResolver {
vec![vec![batch]],
)?)))
}
+
+ async fn resolve_table_at_timestamp(
+ &self,
+ request: &EngineTableRequest,
+ timestamp_millis: i64,
+ ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+ self.timestamps.lock().unwrap().push(timestamp_millis);
+ // Pick the latest snapshot committed at or before the timestamp.
+ let snapshot_id = if timestamp_millis ==
local_millis(BACKEND_FAILURE_TIME) {
+ 500
+ } else if timestamp_millis >= local_millis(SNAPSHOT_20_TIME) {
+ 20
+ } else if timestamp_millis >= local_millis(SNAPSHOT_10_TIME) {
+ 10
+ } else {
+ return Err(DataFusionError::Plan(format!(
+ "no snapshot exists at or before {timestamp_millis}"
+ )));
+ };
+ self.resolve_table_snapshot(request, snapshot_id).await
+ }
}
#[async_trait]
@@ -582,20 +618,71 @@ async fn
engine_snapshot_uses_historical_data_and_schema() {
}
}
+#[tokio::test]
+async fn engine_timestamp_passes_exact_millis_and_uses_the_resolved_snapshot()
{
+ let (env, resolver) = snapshot_env().await;
+ for native in [false, true] {
+ for (timestamp, expected) in [
+ (SNAPSHOT_10_TIME, ["old-a", "old-b"]),
+ ("2026-10-01 23:59:59", ["old-a", "old-b"]),
+ (SNAPSHOT_20_TIME, ["new-a", "new-d"]),
+ ("2026-10-03 00:00:00", ["new-a", "new-d"]),
+ ] {
+ resolver.timestamps.lock().unwrap().clear();
+ let batches = routed_sql(
+ &env,
+ native,
+ &format!(
+ "SELECT h.historical FROM {CATALOG}.{DB}.it TIMESTAMP AS
OF '{timestamp}' AS h \
+ ORDER BY h.id"
+ ),
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_column_values(&batches, "historical"),
+ expected,
+ "{timestamp}"
+ );
+ assert_eq!(
+ *resolver.timestamps.lock().unwrap(),
+ [local_millis(timestamp)]
+ );
+ }
+
+ let err = routed_sql(
+ &env,
+ native,
+ &format!("SELECT payload FROM {CATALOG}.{DB}.it TIMESTAMP AS OF
'{SNAPSHOT_20_TIME}'"),
+ )
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("payload"), "{err}");
+ }
+}
+
#[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"]);
+ for selector in [
+ "VERSION AS OF 10".to_string(),
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ ] {
+ let batches = env
+ .ctx
+ .sql(&format!(
+ "SELECT h.historical FROM {CATALOG}.{DB}.it {selector} AS h
WHERE h.id = 2"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(string_column_values(&batches, "historical"), ["old-b"]);
+ }
let err = env
.ctx
@@ -629,21 +716,27 @@ async fn
sql_context_snapshot_remains_readable_when_current_resolution_fails() {
#[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}"
- );
+ for selector in [
+ "VERSION AS OF 10".to_string(),
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ ] {
+ for suffix in ["", " WHERE FALSE", " LIMIT 0"] {
+ let err = env
+ .ctx
+ .ctx()
+ .sql(&format!(
+ "SELECT * FROM {CATALOG}.{DB}.it {selector}{suffix}"
+ ))
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("current table is unavailable"),
+ "{err}"
+ );
+ }
}
assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+ assert!(resolver.timestamps.lock().unwrap().is_empty());
}
async fn current_unavailable_env() -> (TestEnv, Arc<SnapshotEngineResolver>) {
@@ -701,7 +794,7 @@ async fn engine_errors_propagate_to_context_table() {
#[tokio::test]
async fn engine_snapshots_can_be_joined_with_each_other_and_paimon() {
- let (env, _) = snapshot_env().await;
+ let (env, resolver) = snapshot_env().await;
for native in [false, true] {
let batches = routed_sql(
&env,
@@ -742,6 +835,50 @@ async fn
engine_snapshots_can_be_joined_with_each_other_and_paimon() {
.await
.unwrap();
assert_eq!(string_column_values(&batches, "historical"), ["old-a"]);
+
+ let batches = routed_sql(
+ &env,
+ native,
+ &format!(
+ "SELECT a.historical AS old_value, b.historical AS new_value \
+ FROM {CATALOG}.{DB}.it TIMESTAMP AS OF '{SNAPSHOT_10_TIME}' AS a \
+ JOIN {CATALOG}.{DB}.it VERSION AS OF 20 AS b ON a.id = b.id"
+ ),
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(string_column_values(&batches, "old_value"), ["old-a"]);
+ assert_eq!(string_column_values(&batches, "new_value"), ["new-a"]);
+
+ // Paimon tables keep Paimon's own timestamp resolution.
+ resolver.timestamps.lock().unwrap().clear();
+ let batches = routed_sql(
+ &env,
+ native,
+ &format!(
+ "SELECT p.id, h.historical \
+ FROM {CATALOG}.{DB}.it TIMESTAMP AS OF '{SNAPSHOT_10_TIME}' AS h \
+ JOIN {CATALOG}.{DB}.pt TIMESTAMP AS OF '2999-01-01 00:00:00' AS p
ON h.id = p.id \
+ ORDER BY h.id"
+ ),
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_column_values(&batches, "historical"),
+ ["old-a", "old-b"]
+ );
+ assert_eq!(column_i32(&batches), [1, 2]);
+ assert_eq!(
+ *resolver.timestamps.lock().unwrap(),
+ [local_millis(SNAPSHOT_10_TIME)]
+ );
}
}
@@ -785,21 +922,42 @@ async fn
engine_snapshot_supports_count_and_empty_results() {
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"),
+ for (table, selector, message) in [
+ (
+ "it",
+ "VERSION AS OF 404".to_string(),
+ "snapshot 404 does not exist or has expired",
+ ),
+ (
+ "it",
+ "VERSION AS OF 500".to_string(),
+ "snapshot backend failed",
+ ),
+ ("ghost", "VERSION AS OF 10".to_string(), "not found"),
+ (
+ "it",
+ "TIMESTAMP AS OF '2026-09-30 23:59:59'".to_string(),
+ "no snapshot exists at or before",
+ ),
+ (
+ "it",
+ format!("TIMESTAMP AS OF '{BACKEND_FAILURE_TIME}'"),
+ "snapshot backend failed",
+ ),
+ (
+ "ghost",
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ "not found",
+ ),
] {
let err = routed_sql(
&env,
native,
- &format!(
- "SELECT * FROM {CATALOG}.{DB}.{table} VERSION AS OF
{version} WHERE FALSE"
- ),
+ &format!("SELECT * FROM {CATALOG}.{DB}.{table} {selector}
WHERE FALSE"),
)
.await
.unwrap_err();
- assert!(err.to_string().contains(message), "{err}");
+ assert!(err.to_string().contains(message), "{selector}: {err}");
}
}
}
@@ -813,33 +971,38 @@ async fn engine_snapshot_providers_remain_read_only() {
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,
+ for selector in [
+ "VERSION AS OF 10".to_string(),
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ ] {
+ let df = routed_sql(
+ &env,
+ native,
+ &format!("SELECT * FROM {CATALOG}.{DB}.it {selector}"),
)
.await
- .unwrap_err();
- assert!(err.to_string().contains("write is not supported"), "{err}");
+ .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}");
+ }
}
}
@@ -856,15 +1019,21 @@ async fn
engine_snapshot_resolution_checks_session_options() {
.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);
+ for selector in [
+ "VERSION AS OF 10".to_string(),
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ ] {
+ let err = routed_sql(
+ &env,
+ native,
+ &format!("SELECT * FROM {CATALOG}.{DB}.it {selector}"),
+ )
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains(message), "{selector}:
{err}");
+ assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+ assert!(resolver.timestamps.lock().unwrap().is_empty());
+ }
}
env.ctx.sql(&format!("RESET 'paimon.{key}'")).await.unwrap();
}
@@ -882,6 +1051,8 @@ async fn
engine_snapshot_rejects_unsupported_selectors_and_metadata_tables() {
} else {
("VERSION AS OF 'tag-name'", "integer snapshot id")
};
+ let timestamp = format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'");
+ let timestamp = timestamp.as_str();
for (table, selector, message) in [
("it", selector, message),
(
@@ -891,19 +1062,21 @@ async fn
engine_snapshot_rejects_unsupported_selectors_and_metadata_tables() {
),
(
"it",
- "TIMESTAMP AS OF '2020-01-01 00:00:00'",
- "time travel is not supported",
+ "TIMESTAMP AS OF '2026-10-01'",
+ "Cannot parse time travel timestamp",
),
(
"it$history",
"VERSION AS OF 10",
"time travel is not supported",
),
+ ("it$history", timestamp, "time travel is not supported"),
(
"it$branch_b1",
"VERSION AS OF 10",
"branches are not supported",
),
+ ("it$branch_b1", timestamp, "branches are not supported"),
] {
let err = routed_sql(
&env,
@@ -914,6 +1087,7 @@ async fn
engine_snapshot_rejects_unsupported_selectors_and_metadata_tables() {
.unwrap_err();
assert!(err.to_string().contains(message), "{selector}: {err}");
assert_eq!(resolver.calls.load(Ordering::Relaxed), 0);
+ assert!(resolver.timestamps.lock().unwrap().is_empty());
}
}
}
@@ -926,19 +1100,24 @@ async fn
engine_snapshot_requires_explicit_resolver_support() {
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}");
+ for selector in [
+ "VERSION AS OF 10".to_string(),
+ format!("TIMESTAMP AS OF '{SNAPSHOT_10_TIME}'"),
+ ] {
+ let err = routed_sql(
+ &env,
+ native,
+ &format!("SELECT * FROM {CATALOG}.{DB}.it {selector} 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), "{selector}:
{err}");
+ }
}
}
}