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 f0f5d964 Support REST authorized stream and combined incremental reads
(#1130)
f0f5d964 is described below
commit f0f5d9643a514939baa6ef2d079e37248dc88c68
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 15:37:59 2026 +0800
Support REST authorized stream and combined incremental reads (#1130)
---
.../python/python/pypaimon_rust/datafusion.pyi | 6 +-
crates/paimon/src/table/incremental_scan.rs | 6 +-
crates/paimon/src/table/stream_table_scan.rs | 16 +-
crates/paimon/src/table/table_read.rs | 65 ++--
crates/paimon/src/table/table_scan.rs | 81 +++-
.../rest_catalog/authorized_snapshot_scans.rs | 409 +++++++++++++++++++++
crates/paimon/tests/rest_catalog_test.rs | 4 +
7 files changed, 533 insertions(+), 54 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 8fdf00e5..7b605d68 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -156,7 +156,11 @@ class ReadBuilder:
...
def new_scan(self) -> TableScan: ...
def new_stream_scan(self) -> StreamTableScan:
- """Create a stateful stream scan retaining filters, read type and
ranges."""
+ """Create a stateful stream scan retaining filters, read type and
ranges.
+
+ REST query authorization refreshes on every plan/poll. Returned splits
+ retain that plan's rules; failures do not consume the failing snapshot.
+ """
...
def new_incremental_scan(
self,
diff --git a/crates/paimon/src/table/incremental_scan.rs
b/crates/paimon/src/table/incremental_scan.rs
index acddf033..5eff66b1 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -373,7 +373,6 @@ impl<'a> IncrementalScan<'a> {
/// must exist and supplies snapshot metadata. Snapshot deletion vectors
and
/// automatic global-index pruning do not apply to these historical events.
pub async fn plan_combined_delta(&self) -> crate::Result<Plan> {
-
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let mode = self.resolve_mode();
if mode != IncrementalScanMode::Delta {
return Err(crate::Error::Unsupported {
@@ -388,7 +387,7 @@ impl<'a> IncrementalScan<'a> {
mode: IncrementalScanMode,
trace: Option<&mut ScanTrace>,
) -> crate::Result<Plan> {
-
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+ let scan = self.scan.authorize_snapshot_read().await?;
// Row positions and shuffled chunks refer to one APPEND-delta row
// space. Changelog supports Java's file/bucket shard selection only.
if mode == IncrementalScanMode::Changelog
@@ -438,8 +437,7 @@ impl<'a> IncrementalScan<'a> {
})
.map(str::to_owned)
.collect();
- self.scan
- .plan_incremental_lists_with_trace(&end_snapshot, manifest_lists,
trace)
+ scan.plan_incremental_lists_with_trace(&end_snapshot, manifest_lists,
trace)
.await
}
diff --git a/crates/paimon/src/table/stream_table_scan.rs
b/crates/paimon/src/table/stream_table_scan.rs
index fea63186..96cadba6 100644
--- a/crates/paimon/src/table/stream_table_scan.rs
+++ b/crates/paimon/src/table/stream_table_scan.rs
@@ -121,7 +121,7 @@ impl StreamTableScan {
pub async fn plan(&mut self) -> Result<Option<Plan>> {
// Validate even when the table is empty or no follow-up is available.
- self.scan.validate_snapshot_read()?;
+ let scan = self.scan.authorize_snapshot_read().await?;
if self.next_snapshot_id.is_none() && !self.consumer_restored {
if let Some(consumer_id) = &self.consumer_id {
let next = self
@@ -143,7 +143,7 @@ impl StreamTableScan {
};
let next = snapshot.id() + 1;
let watermark = snapshot.watermark();
- let plan = self.plan_initial_snapshot(snapshot).await?;
+ let plan = self.plan_initial_snapshot(snapshot, scan).await?;
self.next_snapshot_id = Some(next);
self.watermark = watermark;
return Ok(Some(plan));
@@ -186,7 +186,7 @@ impl StreamTableScan {
}
let watermark = snapshot.watermark();
// Keep the failing snapshot retryable if planning or a callback
fails.
- let plan = self.scan.plan_selected_snapshot(snapshot, mode).await?;
+ let plan = scan.plan_selected_snapshot(snapshot, mode).await?;
self.next_snapshot_id = Some(id + 1);
self.watermark = watermark;
if !plan.splits().is_empty() {
@@ -195,9 +195,13 @@ impl StreamTableScan {
}
}
- async fn plan_initial_snapshot(&self, snapshot: Snapshot) -> Result<Plan> {
- let options = self.scan.snapshot_table().schema().options();
- let mut scan = self.scan.clone();
+ async fn plan_initial_snapshot(
+ &self,
+ snapshot: Snapshot,
+ mut scan: TableScan<'static>,
+ ) -> Result<Plan> {
+ let table = scan.snapshot_table().clone();
+ let options = table.schema().options();
let parse = |key, default| -> Result<i32> {
options
.get(key)
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 598e8bc9..4ae7b711 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -534,16 +534,24 @@ impl<'a> PaimonTableRead<'a> {
&self,
data_splits: &[DataSplit],
) -> crate::Result<ArrowRecordBatchStream> {
- // Streaming primary-key splits are read raw below, so decide here for
all.
- if self
- .ensure_authorized_by_splits(&self.table.schema.core_options(),
data_splits)?
- .is_some()
- {
- return Err(super::query_auth::unsupported(
- "a row-kind read cannot apply a row filter or column masking",
- ));
- }
+ let grant =
+
self.ensure_authorized_by_splits(&self.table.schema.core_options(),
data_splits)?;
let data_splits = self.matching_splits(data_splits)?;
+ if let Some(grant) = grant {
+ let mut read = self.clone();
+ // The metadata column travels with the Arrow batch through filters
+ // and masks, but is never an authorization operand or physical
field.
+ read.read_type.insert(0, row_kind_field());
+ return read.read_restricted(&data_splits, &grant, true);
+ }
+ self.read_row_kind_splits(&data_splits)
+ }
+
+ /// Read the already-authorized file set, preserving physical change kinds.
+ fn read_row_kind_splits(
+ &self,
+ data_splits: &[DataSplit],
+ ) -> crate::Result<ArrowRecordBatchStream> {
let schema = audit_schema_for_read_type(&self.read_type, false)?;
let (streaming, materialized): (Vec<_>, Vec<_>) = data_splits
.iter()
@@ -555,14 +563,14 @@ impl<'a> PaimonTableRead<'a> {
let mut streams = Vec::with_capacity(2);
if !materialized.is_empty() {
streams.push(prepend_insert_row_kind_stream(
- self.to_arrow(&materialized)?,
+ self.read_splits(&materialized,
&self.table.schema.core_options())?,
Arc::clone(&schema),
)?);
}
if !streaming.is_empty() {
if self.table.schema().primary_keys().is_empty() {
streams.push(prepend_insert_row_kind_stream(
- self.to_arrow(&streaming)?,
+ self.read_splits(&streaming,
&self.table.schema.core_options())?,
Arc::clone(&schema),
)?);
} else {
@@ -1122,7 +1130,7 @@ impl<'a> PaimonTableRead<'a> {
let grant = self.ensure_authorized_by_splits(&core_options,
data_splits)?;
let data_splits = self.matching_splits(data_splits)?;
match grant {
- Some(grant) => self.read_restricted(&data_splits, &grant),
+ Some(grant) => self.read_restricted(&data_splits, &grant, false),
None => self.read_splits(&data_splits, &core_options),
}
}
@@ -1132,6 +1140,7 @@ impl<'a> PaimonTableRead<'a> {
&self,
data_splits: &[DataSplit],
grant: &super::query_auth::QueryAuthGrant,
+ include_row_kind: bool,
) -> crate::Result<ArrowRecordBatchStream> {
use super::query_auth::{filter_batch, mask_batch, mask_inputs,
unsupported};
use crate::arrow::format::FilePredicates;
@@ -1240,7 +1249,14 @@ impl<'a> PaimonTableRead<'a> {
]));
inner.table = &unresolved_table;
}
- let stream = inner.read_splits(data_splits,
&inner.table.schema.core_options())?;
+ let stream = if include_row_kind {
+ // The source creates rowkind from _VALUE_KIND (or +I for snapshot
+ // rows); do not ask the physical format reader to load it.
+ inner.read_type.remove(0);
+ inner.read_row_kind_splits(data_splits)?
+ } else {
+ inner.read_splits(data_splits, &inner.table.schema.core_options())?
+ };
let filters = rules.filters.clone();
let batch_fields = physical.clone();
@@ -1801,16 +1817,20 @@ fn prepend_insert_row_kind(
})
}
+fn row_kind_field() -> DataField {
+ DataField::new(
+ ROW_KIND_FIELD_ID,
+ ROW_KIND_FIELD_NAME.to_string(),
+ DataType::VarChar(crate::spec::VarCharType::string_type()),
+ )
+}
+
fn audit_schema_for_read_type(
read_type: &[DataField],
include_sequence: bool,
) -> crate::Result<Arc<ArrowSchema>> {
let mut fields = Vec::with_capacity(read_type.len() + 2);
- fields.push(DataField::new(
- ROW_KIND_FIELD_ID,
- ROW_KIND_FIELD_NAME.to_string(),
- DataType::VarChar(crate::spec::VarCharType::string_type()),
- ));
+ fields.push(row_kind_field());
if include_sequence {
fields.push(DataField::new(
SEQUENCE_NUMBER_FIELD_ID,
@@ -3198,12 +3218,11 @@ mod tests {
if message.contains("different rules")),
"one rule set per read, whatever the order"
);
+ assert!(matches!(read.to_arrow_with_row_kind(&splits),
+ Err(crate::Error::Unsupported { ref message }) if
message.contains("different rules")));
}
- // Streaming primary-key splits are read raw.
- assert!(matches!(
- read.to_arrow_with_row_kind(&[restricted]),
- Err(crate::Error::Unsupported { ref message }) if
message.contains("row-kind")
- ));
+ // Row-kind reads preserve the same grant requirement as normal reads.
+ assert!(read.to_arrow_with_row_kind(&[restricted]).is_ok());
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index fa84bf5e..345021b3 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -54,6 +54,7 @@ use crate::table::source::{
use crate::table::ScanTrace;
use futures::{StreamExt, TryStreamExt};
use indexmap::IndexMap;
+use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -1136,6 +1137,7 @@ impl<'a> TableScan<'a> {
scan_all_files: scan.scan_all_files,
incremental_split_mode: scan.incremental_split_mode,
projected_read_field_ids: scan.projected_read_field_ids,
+ query_auth_grant: scan.query_auth_grant,
})))
}
@@ -1146,6 +1148,19 @@ impl<'a> TableScan<'a> {
&scan.table
}
+ /// One authorization scope for a single plan/poll. The long-lived stream
+ /// retains the original ungranted scan so the next poll refreshes the
rules.
+ pub(super) async fn authorize_snapshot_read(&self) -> crate::Result<Self> {
+ match &self.0 {
+ TableScanKind::Paimon(scan) => Ok(Self(TableScanKind::Paimon(
+ scan.authorized_scan().await?.into_owned(),
+ ))),
+ TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+ message: "Snapshot authorization requires a Paimon
table".into(),
+ }),
+ }
+ }
+
pub(super) fn validate_snapshot_read(&self) -> crate::Result<()> {
let TableScanKind::Paimon(scan) = &self.0 else {
unreachable!("snapshot scans only contain Paimon tables")
@@ -1198,14 +1213,15 @@ impl<'a> TableScan<'a> {
scan.split_selection
.get_or_insert_with(Default::default)
.snapshot_mode = Some(mode);
- match mode {
+ let plan = match mode {
SnapshotScanMode::All => {
let fields = scan.projected_read_field_ids()?;
scan.plan_snapshot(snapshot, fields.as_ref(), None).await
}
SnapshotScanMode::Delta =>
scan.plan_snapshot_delta(&snapshot).await,
SnapshotScanMode::Changelog =>
scan.plan_snapshot_changelog(&snapshot, None).await,
- }
+ }?;
+ scan.finish_plan(plan).await
}
pub(crate) fn new(
@@ -1461,8 +1477,10 @@ impl<'a> TableScan<'a> {
) -> crate::Result<Plan> {
match &self.0 {
TableScanKind::Paimon(scan) => {
- scan.plan_incremental_lists(end_snapshot, manifest_lists,
trace)
- .await
+ let plan = scan
+ .plan_incremental_lists(end_snapshot, manifest_lists,
trace)
+ .await?;
+ scan.finish_plan(plan).await
}
TableScanKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support incremental batch
scans".to_string(),
@@ -1590,6 +1608,9 @@ struct PaimonTableScan<'a> {
scan_all_files: bool,
incremental_split_mode: Option<IncrementalSplitMode>,
projected_read_field_ids: Option<HashSet<i32>>,
+ /// Only set on an ephemeral copy after authorization. Never stored back
into
+ /// StreamTableScan, and never reconstructed from serialized split
metadata.
+ query_auth_grant: Option<Arc<super::query_auth::QueryAuthGrant>>,
}
impl<'a> PaimonTableScan<'a> {
@@ -1669,6 +1690,7 @@ impl<'a> PaimonTableScan<'a> {
scan_all_files: false,
incremental_split_mode: None,
projected_read_field_ids: None,
+ query_auth_grant: None,
}
}
@@ -1726,9 +1748,7 @@ impl<'a> PaimonTableScan<'a> {
/// for `scan.version`; the strict selectors mirror Java's typed
/// `scan.snapshot-id` / `scan.tag-name` handling.
pub async fn plan(&self) -> crate::Result<Plan> {
- let grant = self.authorize_query().await?;
- let restricted = grant.as_deref().map(|grant|
self.restricted_by(grant));
- let scan = restricted.as_ref().unwrap_or(self);
+ let scan = self.authorized_scan().await?;
if let Some((start, end)) = scan
.table
.schema()
@@ -1736,8 +1756,7 @@ impl<'a> PaimonTableScan<'a> {
.incremental_timestamp_window()?
{
let plan = scan.plan_timestamp_window(start, end, None).await?;
- scan.check_planned_files(&plan, grant.is_some()).await?;
- return Ok(plan.planned(grant));
+ return Ok(plan);
}
let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
let plan = match
super::time_travel::resolve_snapshot(&scan.table).await? {
@@ -1747,15 +1766,12 @@ impl<'a> PaimonTableScan<'a> {
}
None => Plan::new(Vec::new()),
};
- scan.check_planned_files(&plan, grant.is_some()).await?;
- Ok(plan.planned(grant))
+ scan.finish_plan(plan).await
}
/// Plan the full scan and return metadata-pruning trace counters.
pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
- let grant = self.authorize_query().await?;
- let restricted = grant.as_deref().map(|grant|
self.restricted_by(grant));
- let scan = restricted.as_ref().unwrap_or(self);
+ let scan = self.authorized_scan().await?;
let mut trace = ScanTrace {
limit: scan.limit,
..Default::default()
@@ -1771,8 +1787,7 @@ impl<'a> PaimonTableScan<'a> {
.await?;
trace.snapshot_id = plan.snapshot_id();
trace.planned_data_file_bytes = plan.planned_data_file_bytes();
- scan.check_planned_files(&plan, grant.is_some()).await?;
- return Ok((plan.planned(grant), trace));
+ return Ok((plan, trace));
}
let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
let plan = match
super::time_travel::resolve_snapshot(&scan.table).await? {
@@ -1790,12 +1805,11 @@ impl<'a> PaimonTableScan<'a> {
}
None => Plan::new(Vec::new()),
};
- scan.check_planned_files(&plan, grant.is_some()).await?;
- Ok((plan.planned(grant), trace))
+ Ok((scan.finish_plan(plan).await?, trace))
}
/// Keep pruning off masked columns and apply limits after row filtering.
- fn restricted_by(&self, grant: &super::query_auth::QueryAuthGrant) -> Self
{
+ fn restricted_by(&self, grant: &Arc<super::query_auth::QueryAuthGrant>) ->
Self {
let rules = grant.rules();
let fields = self.table.schema().fields();
let masked: HashSet<&str> = rules
@@ -1810,6 +1824,7 @@ impl<'a> PaimonTableScan<'a> {
.any(|name| masked.contains(name.as_str()))
};
let mut scan = self.clone();
+ scan.query_auth_grant = Some(Arc::clone(grant));
scan.data_predicates.retain(|p| !touches_masked(p));
if scan.bucket_predicate.as_ref().is_some_and(touches_masked) {
scan.bucket_predicate = None;
@@ -1930,6 +1945,26 @@ impl<'a> PaimonTableScan<'a> {
.await
}
+ async fn authorized_scan(&self) -> crate::Result<Cow<'_, Self>> {
+ // Timestamp-window planning delegates to combined incremental planning
+ // within the same operation. Reuse its scope, not another REST
request.
+ if self.query_auth_grant.is_some() {
+ self.validate_read_options()?;
+ return Ok(Cow::Borrowed(self));
+ }
+ let grant = self.authorize_query().await?;
+ Ok(match grant {
+ Some(grant) => Cow::Owned(self.restricted_by(&grant)),
+ None => Cow::Borrowed(self),
+ })
+ }
+
+ async fn finish_plan(&self, plan: Plan) -> crate::Result<Plan> {
+ self.check_planned_files(&plan, self.query_auth_grant.is_some())
+ .await?;
+ Ok(plan.planned(self.query_auth_grant.clone()))
+ }
+
/// Authorize this scan and return the grant for the caller to stamp.
async fn authorize_query(
&self,
@@ -2016,7 +2051,13 @@ impl<'a> PaimonTableScan<'a> {
/// included.
fn validate_read_options(&self) -> crate::Result<()> {
let options = CoreOptions::new(self.table.schema().options());
- options.ensure_read_authorized()?;
+ if !self
+ .query_auth_grant
+ .as_ref()
+ .is_some_and(|grant| grant.matches_table(&self.table))
+ {
+ options.ensure_read_authorized()?;
+ }
options.validate_data_file_path_directory()
}
diff --git a/crates/paimon/tests/rest_catalog/authorized_snapshot_scans.rs
b/crates/paimon/tests/rest_catalog/authorized_snapshot_scans.rs
new file mode 100644
index 00000000..75213081
--- /dev/null
+++ b/crates/paimon/tests/rest_catalog/authorized_snapshot_scans.rs
@@ -0,0 +1,409 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::*;
+
+async fn append(g: &Guarded, batch: RecordBatch) {
+ let mut options = Options::new();
+ options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", g._tmp.path().display()),
+ );
+ let catalog = FileSystemCatalog::new(options).unwrap();
+ let plain = catalog.get_table(&g.identifier).await.unwrap();
+ write_batch(&plain, batch, "auth-stream-append").await;
+}
+
+async fn read_frame(
+ builder: &paimon::table::ReadBuilder<'_>,
+ plan: &paimon::table::Plan,
+ kinds: bool,
+) -> paimon::Result<Vec<RecordBatch>> {
+ let read = builder.new_read()?;
+ if kinds {
+ read.to_arrow_with_row_kind(plan.splits())?
+ } else {
+ read.to_arrow(plan.splits())?
+ }
+ .try_collect()
+ .await
+}
+
+#[tokio::test]
+async fn
authorized_stream_refreshes_rules_but_old_frames_keep_captured_rules() {
+ let g = written("stream_refresh", people, vec![people_rows(1..=3)]).await;
+ g.set_rules(
+ &[int_leaf(0, "id", "GREATER_THAN", 1)],
+ &[("name", upper(1, "name"))],
+ );
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["id", "name"]).unwrap();
+ let mut stream = builder.new_stream_scan().unwrap();
+ let first = stream.plan().await.unwrap().unwrap();
+ assert_eq!(
+ ids(&read_frame(&builder, &first, false).await.unwrap()),
+ [2, 3]
+ );
+ assert_eq!(stream.checkpoint(), Some(2));
+ g.set_rules(
+ &[int_leaf(0, "id", "GREATER_OR_EQUAL", 5)],
+ &[("name", upper(1, "name"))],
+ );
+ append(&g, people_rows(4..=5)).await;
+ let next = stream.plan().await.unwrap().unwrap();
+ assert_eq!(
+ id_names(&read_frame(&builder, &next, true).await.unwrap()),
+ [(5, Some("ERIN".into()))]
+ );
+ assert_eq!(stream.checkpoint(), Some(3));
+ assert_eq!(
+ id_names(&read_frame(&builder, &first, true).await.unwrap()),
+ [(2, Some("BOB".into())), (3, Some("CAROL".into()))]
+ );
+ assert!(stream.plan().await.unwrap().is_none());
+ g.set_rules(&[], &[]);
+ append(&g, people_rows(6..=6)).await;
+ assert_eq!(
+ ids(
+ &read_frame(&builder, &stream.plan().await.unwrap().unwrap(),
false)
+ .await
+ .unwrap()
+ ),
+ [6]
+ );
+}
+
+#[tokio::test]
+async fn
authorization_failure_preserves_initial_follow_up_and_empty_poll_progress() {
+ let g = written("stream_retry", people, vec![people_rows(1..=2)]).await;
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["id", "name"]).unwrap();
+ let mut stream = builder.new_stream_scan().unwrap();
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_retry", vec!["id".into()]);
+ assert_forbidden(stream.plan().await.unwrap_err());
+ assert_eq!(stream.checkpoint(), None);
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_retry", vec!["id".into(),
"name".into()]);
+ assert_eq!(stream.plan().await.unwrap().unwrap().snapshot_id(), Some(1));
+ append(&g, people_rows(3..=3)).await;
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_retry", vec!["id".into()]);
+ assert_forbidden(stream.plan().await.unwrap_err());
+ assert_eq!(stream.checkpoint(), Some(2));
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_retry", vec!["id".into(),
"name".into()]);
+ assert_eq!(
+ ids(
+ &read_frame(&builder, &stream.plan().await.unwrap().unwrap(),
false)
+ .await
+ .unwrap()
+ ),
+ [3]
+ );
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_retry", vec!["id".into()]);
+ assert_forbidden(stream.plan().await.unwrap_err());
+ assert_eq!(stream.checkpoint(), Some(3));
+}
+
+fn keyed_people(options: &[(&str, &str)]) -> Schema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("changelog-producer", "input");
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+}
+
+fn changes() -> RecordBatch {
+ let batch = people_batch(&[(1, "old"), (1, "new"), (2, "deleted")]);
+ let mut fields =
batch.schema().fields().iter().cloned().collect::<Vec<_>>();
+ fields.push(Arc::new(ArrowField::new(
+ "_VALUE_KIND",
+ ArrowDataType::Int8,
+ false,
+ )));
+ let mut columns = batch.columns().to_vec();
+ columns.push(Arc::new(arrow_array::Int8Array::from(vec![1, 2, 3])));
+ RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)), columns).unwrap()
+}
+
+#[tokio::test]
+async fn
authorized_stream_keeps_physical_change_kinds_after_filters_and_masks() {
+ let g = written(
+ "stream_kinds",
+ keyed_people,
+ vec![people_batch(&[(1, "old"), (2, "deleted")]), changes()],
+ )
+ .await;
+ g.set_rules(
+ &[int_leaf(0, "id", "EQUAL", 1)],
+ &[("name", upper(1, "name"))],
+ );
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["name"]).unwrap();
+ let mut stream = builder.new_stream_scan().unwrap();
+ stream.restore(Some(2)).unwrap();
+ let plan = stream.plan().await.unwrap().unwrap();
+ let batches = read_frame(&builder, &plan, true).await.unwrap();
+ let rows = batches
+ .iter()
+ .flat_map(|batch| {
+ let kinds = column::<StringArray>(batch, "rowkind");
+ let names = column::<StringArray>(batch, "name");
+ (0..batch.num_rows())
+ .map(|i| (kinds.value(i).to_string(),
names.value(i).to_string()))
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ rows,
+ [("-U".into(), "OLD".into()), ("+U".into(), "NEW".into())]
+ );
+ assert!(batches.iter().all(|batch| batch.num_columns() == 2));
+ let serialized = plan.splits()[0].serialize_split_v1().unwrap();
+ let detached =
paimon::table::DataSplit::deserialize_split_v1(&serialized).unwrap();
+ assert_refused(
+ builder
+ .new_read()
+ .unwrap()
+ .to_arrow_with_row_kind(&[detached])
+ .err()
+ .expect("wire bytes cannot mint a grant"),
+ );
+ let mut empty_projection = g.table.new_read_builder();
+ empty_projection.with_projection(&[]).unwrap();
+ let batches = read_frame(&empty_projection, &plan, true).await.unwrap();
+ assert_eq!(
+ batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+ 2
+ );
+ assert!(batches.iter().all(|batch| batch.num_columns() == 1));
+}
+
+#[tokio::test]
+async fn
authorized_row_kinds_keep_blob_resolution_after_filters_for_both_split_kinds() {
+ let (_source, good, missing) = blob_payloads();
+ for primary_key in [false, true] {
+ let g = blob_mask_rows(primary_key, vec![None, Some(&missing),
Some(&good)]).await;
+ g.set_rules(
+ &[
+ int_leaf(0, "id", "NOT_EQUAL", 2),
+ int_leaf(0, "id", "NOT_EQUAL", 5),
+ ],
+ &[("payload", field_ref_mask(1, "payload", "BLOB"))],
+ );
+ let builder = g.table.new_read_builder();
+ let mut scan = builder.new_stream_scan().unwrap();
+ let initial = scan.plan().await.unwrap().unwrap();
+ append(
+ &g,
+ RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![4, 5])) as
arrow_array::ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(good.as_slice()),
+ Some(missing.as_slice()),
+ ])) as arrow_array::ArrayRef,
+ ),
+ (
+ "label",
+ Arc::new(StringArray::from(vec!["row4", "row5"])) as
arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await;
+ let follow_up = scan.plan().await.unwrap().unwrap();
+ let splits = [initial.splits(), follow_up.splits()].concat();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow_with_row_kind(&splits)
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&batches), [1, 3, 4]);
+ for batch in &batches {
+ assert!(column::<StringArray>(batch, "rowkind")
+ .iter()
+ .all(|kind| kind == Some("+I")));
+ for payload in column::<LargeBinaryArray>(batch, "payload")
+ .iter()
+ .flatten()
+ {
+ assert_eq!(payload, b"good");
+ }
+ }
+ let mut limited = g.table.new_read_builder();
+ limited.with_limit(1);
+ let batches: Vec<RecordBatch> = limited
+ .new_read()
+ .unwrap()
+ .to_arrow_with_row_kind(&splits)
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
1);
+ }
+}
+
+#[tokio::test]
+async fn
combined_authorized_events_filter_before_limit_and_stamp_every_split() {
+ for keyed in [false, true] {
+ let g = if keyed {
+ written(
+ "events_keyed",
+ keyed_people,
+ vec![people_rows(1..=2), people_rows(3..=4),
people_rows(5..=6)],
+ )
+ .await
+ } else {
+ written(
+ "events_append",
+ people,
+ vec![people_rows(1..=2), people_rows(3..=4),
people_rows(5..=6)],
+ )
+ .await
+ };
+ g.set_rules(
+ &[int_leaf(0, "id", "GREATER_THAN", 4)],
+ &[("name", upper(1, "name"))],
+ );
+ let mut builder = g.table.new_read_builder();
+ builder
+ .with_projection(&["id"])
+ .unwrap()
+ .with_limit(1)
+ .with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("name", Datum::String("FRANK".into()))
+ .unwrap(),
+ );
+ let mode = if keyed {
+ paimon::table::IncrementalScanMode::Changelog
+ } else {
+ paimon::table::IncrementalScanMode::Delta
+ };
+ let plan = builder
+ .new_incremental_scan(mode, 0, 3)
+ .plan_combined()
+ .await
+ .unwrap();
+ assert_eq!(ids(&read_frame(&builder, &plan, false).await.unwrap()),
[6]);
+ assert_eq!(ids(&read_frame(&builder, &plan, true).await.unwrap()),
[6]);
+ let requested = g.ctx.server.auth_selects("default",
g.identifier.object());
+ assert!(requested
+ .iter()
+ .all(|select| select.as_ref().unwrap().len() == 2));
+ // Java's high-level timestamp-window path shares this authorization
scope.
+ let window = g.table.copy_with_options(HashMap::from([(
+ "incremental-between-timestamp".into(),
+ "0,9223372036854775807".into(),
+ )]));
+ let mut window_builder = window.new_read_builder();
+ window_builder.with_projection(&["id"]).unwrap();
+ let plan = window_builder.new_scan().plan().await.unwrap();
+ assert_eq!(
+ ids(&read_frame(&window_builder, &plan, true).await.unwrap()),
+ [5, 6]
+ );
+ // Zero-length ranges must still request column authorization.
+ g.ctx
+ .server
+ .set_column_auth("default", g.identifier.object(),
vec!["id".into()]);
+ assert_forbidden(
+ builder
+ .new_incremental_scan(mode, 3, 3)
+ .plan_combined()
+ .await
+ .unwrap_err(),
+ );
+ }
+}
+
+#[tokio::test]
+async fn stream_authorizes_empty_tables_and_filtered_frames_before_advancing()
{
+ let g = written("stream_empty", people, vec![]).await;
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["id", "name"]).unwrap();
+ let mut stream = builder.new_stream_scan().unwrap();
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_empty", vec!["id".into()]);
+ assert_forbidden(stream.plan().await.unwrap_err());
+ assert_eq!(stream.checkpoint(), None);
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_empty", vec!["id".into(),
"name".into()]);
+ assert!(stream.plan().await.unwrap().is_none());
+ append(&g, people_rows(1..=2)).await;
+ stream.with_bucket_filter(|_| Ok(false));
+ let frame = stream.plan().await.unwrap().unwrap();
+ assert!(frame.splits().is_empty());
+ assert_eq!(stream.checkpoint(), Some(2));
+ append(&g, people_rows(3..=3)).await;
+ assert!(stream.plan().await.unwrap().is_none());
+ assert_eq!(stream.checkpoint(), Some(3));
+ g.ctx
+ .server
+ .set_column_auth("default", "stream_empty", vec!["id".into()]);
+ assert_forbidden(stream.plan().await.unwrap_err());
+ assert_eq!(stream.checkpoint(), Some(3));
+}
+
+#[tokio::test]
+async fn stream_rejects_schema_or_identity_drift_without_consuming_the_frame()
{
+ for recreate in [false, true] {
+ let g = written("stream_drift", people,
vec![people_rows(1..=2)]).await;
+ let mut stream = g.table.new_stream_scan().unwrap();
+ if recreate {
+ g.ctx
+ .server
+ .set_table_uuid("default", "stream_drift", "replacement-uuid");
+ } else {
+ g.ctx
+ .server
+ .set_table_schema_id("default", "stream_drift",
people(GUARDED), 7);
+ }
+ assert_drifted(
+ stream.plan().await.unwrap_err(),
+ if recreate {
+ "now resolves to uuid"
+ } else {
+ "now resolves to schema"
+ },
+ );
+ assert_eq!(stream.checkpoint(), None);
+ }
+}
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index 8251943b..557ec5b0 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -5830,3 +5830,7 @@ async fn test_query_auth_explicit_partition_selector() {
assert_forbidden(selector_only.new_scan().plan().await.unwrap_err());
}
}
+
+#[cfg(not(windows))]
+#[path = "rest_catalog/authorized_snapshot_scans.rs"]
+mod authorized_snapshot_scans;