Skip to content

Commit 2760b14

Browse files
authored
feat: support batch incremental delta scans (#508)
1 parent ed64c7d commit 2760b14

10 files changed

Lines changed: 885 additions & 6 deletions

File tree

crates/paimon/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ pub use catalog::FileSystemCatalog;
4747

4848
pub use table::{
4949
CommitMessage, DataEvolutionDeleteWriter, DataEvolutionWriter, DataSplit, DataSplitBuilder,
50-
DeletionFile, PartitionBucket, Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder,
51-
RenamingSnapshotCommit, RowRange, ScanTrace, SnapshotCommit, SnapshotManager, Table,
52-
TableCommit, TableRead, TableScan, TableUpdate, TableWrite, TagManager, WriteBuilder,
50+
DeletionFile, IncrementalPlan, IncrementalScan, IncrementalScanMode, IncrementalSplit,
51+
PartitionBucket, Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder, RenamingSnapshotCommit,
52+
RowRange, ScanTrace, SnapshotCommit, SnapshotManager, Table, TableCommit, TableRead, TableScan,
53+
TableUpdate, TableWrite, TagManager, WriteBuilder,
5354
};
5455

5556
pub use table::{

crates/paimon/src/table/format_read_builder.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ impl<'a> FormatReadBuilder<'a> {
4545
}
4646
}
4747

48+
pub(crate) fn table(&self) -> &'a Table {
49+
self.table
50+
}
51+
4852
pub(crate) fn with_projection(&mut self, columns: &[&str]) -> Result<&mut Self> {
4953
let projection_names = columns.iter().map(|c| (*c).to_string()).collect::<Vec<_>>();
5054
self.read_type = Some(resolve_projected_fields(
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
use super::{DataSplit, SnapshotManager, Table, TableScan};
19+
use crate::spec::{CommitKind, CoreOptions};
20+
21+
/// Batch incremental scan mode.
22+
///
23+
/// Range semantics: `(start_exclusive, end_inclusive]` — start is exclusive and
24+
/// end is inclusive. An empty range (`start == end`) yields an empty plan.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum IncrementalScanMode {
27+
/// Read data files from APPEND snapshots in the range (delta manifests).
28+
Delta,
29+
/// Read changelog manifest files in the range.
30+
///
31+
/// Not fully implemented in this release; planning returns
32+
/// [`Error::Unsupported`](crate::Error::Unsupported).
33+
Changelog,
34+
/// Resolve to [`Delta`](Self::Delta) when `changelog-producer=none`,
35+
/// otherwise to [`Changelog`](Self::Changelog).
36+
Auto,
37+
/// Diff before/after snapshots.
38+
///
39+
/// Not fully implemented in this release; planning returns
40+
/// [`Error::Unsupported`](crate::Error::Unsupported).
41+
Diff,
42+
}
43+
44+
/// A unit of work produced by an incremental plan.
45+
#[derive(Debug, Clone)]
46+
pub enum IncrementalSplit {
47+
Data(DataSplit),
48+
/// Per-(partition, bucket) diff pair. Memory bounded by one bucket's data.
49+
DiffPair {
50+
before: Vec<DataSplit>,
51+
after: Vec<DataSplit>,
52+
},
53+
}
54+
55+
/// Planned incremental scan: resolved mode plus splits.
56+
#[derive(Debug, Clone)]
57+
pub struct IncrementalPlan {
58+
mode: IncrementalScanMode,
59+
splits: Vec<IncrementalSplit>,
60+
}
61+
62+
impl IncrementalPlan {
63+
pub fn new(mode: IncrementalScanMode, splits: Vec<IncrementalSplit>) -> Self {
64+
Self { mode, splits }
65+
}
66+
67+
/// Resolved mode (`Auto` already collapsed to `Delta` / `Changelog`).
68+
pub fn mode(&self) -> IncrementalScanMode {
69+
self.mode
70+
}
71+
72+
pub fn splits(&self) -> &[IncrementalSplit] {
73+
&self.splits
74+
}
75+
76+
pub fn data_splits(&self) -> Vec<DataSplit> {
77+
self.splits
78+
.iter()
79+
.filter_map(|split| match split {
80+
IncrementalSplit::Data(data) => Some(data.clone()),
81+
IncrementalSplit::DiffPair { .. } => None,
82+
})
83+
.collect()
84+
}
85+
}
86+
87+
/// Batch incremental scan over a snapshot id range.
88+
pub struct IncrementalScan<'a> {
89+
table: &'a Table,
90+
scan: TableScan<'a>,
91+
snapshot_manager: SnapshotManager,
92+
mode: IncrementalScanMode,
93+
start_exclusive: i64,
94+
end_inclusive: i64,
95+
}
96+
97+
impl<'a> IncrementalScan<'a> {
98+
pub(crate) fn for_table(
99+
table: &'a Table,
100+
mode: IncrementalScanMode,
101+
start_exclusive: i64,
102+
end_inclusive: i64,
103+
) -> Self {
104+
let scan = TableScan::new(table, None, Vec::new(), None, None, None);
105+
Self::new(table, scan, mode, start_exclusive, end_inclusive)
106+
}
107+
108+
pub(crate) fn new(
109+
table: &'a Table,
110+
scan: TableScan<'a>,
111+
mode: IncrementalScanMode,
112+
start_exclusive: i64,
113+
end_inclusive: i64,
114+
) -> Self {
115+
let snapshot_manager =
116+
SnapshotManager::new(table.file_io().clone(), table.location().to_string());
117+
Self {
118+
table,
119+
scan,
120+
snapshot_manager,
121+
mode,
122+
start_exclusive,
123+
end_inclusive,
124+
}
125+
}
126+
127+
pub async fn plan(&self) -> crate::Result<IncrementalPlan> {
128+
let mode = self.resolve_mode();
129+
self.validate_snapshot_range(mode).await?;
130+
if self.start_exclusive == self.end_inclusive {
131+
return Ok(IncrementalPlan::new(mode, Vec::new()));
132+
}
133+
match mode {
134+
IncrementalScanMode::Delta => self.plan_delta(mode).await,
135+
IncrementalScanMode::Changelog => self.plan_changelog(mode).await,
136+
IncrementalScanMode::Auto => unreachable!("Auto must resolve before planning"),
137+
IncrementalScanMode::Diff => self.plan_diff(mode).await,
138+
}
139+
}
140+
141+
fn resolve_mode(&self) -> IncrementalScanMode {
142+
match self.mode {
143+
IncrementalScanMode::Auto => {
144+
let core_options = CoreOptions::new(self.table.schema().options());
145+
let producer = core_options.changelog_producer();
146+
if producer.eq_ignore_ascii_case("none") {
147+
IncrementalScanMode::Delta
148+
} else {
149+
IncrementalScanMode::Changelog
150+
}
151+
}
152+
mode => mode,
153+
}
154+
}
155+
156+
async fn validate_snapshot_range(&self, mode: IncrementalScanMode) -> crate::Result<()> {
157+
let earliest = self
158+
.snapshot_manager
159+
.earliest_snapshot_id()
160+
.await?
161+
.ok_or_else(|| crate::Error::DataInvalid {
162+
message: "No snapshots available for incremental scan".to_string(),
163+
source: None,
164+
})?;
165+
let latest = self
166+
.snapshot_manager
167+
.get_latest_snapshot_id()
168+
.await?
169+
.ok_or_else(|| crate::Error::DataInvalid {
170+
message: "No snapshots available for incremental scan".to_string(),
171+
source: None,
172+
})?;
173+
let min_start = match mode {
174+
IncrementalScanMode::Diff => earliest,
175+
IncrementalScanMode::Delta | IncrementalScanMode::Changelog => earliest - 1,
176+
IncrementalScanMode::Auto => unreachable!("Auto must resolve before validation"),
177+
};
178+
if self.start_exclusive < min_start
179+
|| self.end_inclusive > latest
180+
|| self.start_exclusive > self.end_inclusive
181+
{
182+
return Err(crate::Error::DataInvalid {
183+
message: format!(
184+
"Incremental snapshot range [{}, {}] is out of available range [{}, {}] for {:?}",
185+
self.start_exclusive, self.end_inclusive, min_start, latest, mode
186+
),
187+
source: None,
188+
});
189+
}
190+
Ok(())
191+
}
192+
193+
async fn plan_delta(&self, mode: IncrementalScanMode) -> crate::Result<IncrementalPlan> {
194+
let mut splits = Vec::new();
195+
for snapshot_id in (self.start_exclusive + 1)..=self.end_inclusive {
196+
let snapshot = self.snapshot_manager.get_snapshot(snapshot_id).await?;
197+
if snapshot.commit_kind() != &CommitKind::APPEND {
198+
continue;
199+
}
200+
let plan = self.scan.plan_snapshot_delta(&snapshot).await?;
201+
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
202+
}
203+
Ok(IncrementalPlan::new(mode, splits))
204+
}
205+
206+
async fn plan_changelog(&self, mode: IncrementalScanMode) -> crate::Result<IncrementalPlan> {
207+
let _ = mode;
208+
Err(crate::Error::Unsupported {
209+
message: "Batch incremental Changelog scan is not implemented yet".to_string(),
210+
})
211+
}
212+
213+
async fn plan_diff(&self, mode: IncrementalScanMode) -> crate::Result<IncrementalPlan> {
214+
let _ = mode;
215+
Err(crate::Error::Unsupported {
216+
message: "Batch incremental Diff scan is not implemented yet".to_string(),
217+
})
218+
}
219+
}

crates/paimon/src/table/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod global_index_drop_builder;
4848
pub(crate) mod global_index_scanner;
4949
mod global_index_types;
5050
mod hybrid_search_builder;
51+
mod incremental_scan;
5152
mod kv_file_reader;
5253
mod kv_file_writer;
5354
mod lumina_index_build_builder;
@@ -94,6 +95,9 @@ pub use global_index_types::{
9495
pub use hybrid_search_builder::{
9596
HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute, HybridSearchRouteKind,
9697
};
98+
pub use incremental_scan::{
99+
IncrementalPlan, IncrementalScan, IncrementalScanMode, IncrementalSplit,
100+
};
97101
pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
98102
pub use read_builder::ReadBuilder;
99103
pub use rest_env::RESTEnv;

crates/paimon/src/table/read_builder.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
2323
use super::bucket_filter::{extract_predicate_for_keys, split_partition_and_data_predicates};
2424
use super::format_read_builder::FormatReadBuilder;
25+
use super::incremental_scan::{IncrementalScan, IncrementalScanMode};
2526
use super::partition_filter::PartitionFilter;
2627
use super::table_read::TableRead;
2728
use super::{Table, TableScan};
@@ -210,6 +211,32 @@ impl<'a> ReadBuilder<'a> {
210211
}
211212
}
212213

214+
/// Create a batch incremental scan over snapshot id range
215+
/// `(start_exclusive, end_inclusive]`.
216+
///
217+
/// Filters and projection configured on this builder are pushed into the
218+
/// incremental plan (partition / bucket pruning on the delta path).
219+
pub fn new_incremental_scan(
220+
&self,
221+
mode: IncrementalScanMode,
222+
start_exclusive: i64,
223+
end_inclusive: i64,
224+
) -> IncrementalScan<'a> {
225+
match &self.0 {
226+
ReadBuilderKind::Paimon(builder) => IncrementalScan::new(
227+
builder.table,
228+
builder.new_scan(),
229+
mode,
230+
start_exclusive,
231+
end_inclusive,
232+
),
233+
// Format tables share the API surface; planning fails with Unsupported.
234+
ReadBuilderKind::Format(builder) => {
235+
IncrementalScan::for_table(builder.table(), mode, start_exclusive, end_inclusive)
236+
}
237+
}
238+
}
239+
213240
/// Create a table read for consuming splits (e.g. from a scan plan).
214241
pub fn new_read(&self) -> Result<TableRead<'a>> {
215242
match &self.0 {

crates/paimon/src/table/table_read.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use super::data_evolution_reader::DataEvolutionReader;
1919
use super::data_file_reader::DataFileReader;
2020
use super::format_table_read::FormatTableRead;
21+
use super::incremental_scan::{IncrementalPlan, IncrementalScanMode, IncrementalSplit};
2122
use super::kv_file_reader::{KeyValueFileReader, KeyValueReadConfig};
2223
use super::read_builder::split_scan_predicates;
2324
use super::{ArrowRecordBatchStream, Table};
@@ -107,6 +108,22 @@ impl<'a> TableRead<'a> {
107108
TableReadKind::Format(read) => read.to_arrow(data_splits),
108109
}
109110
}
111+
112+
/// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
113+
///
114+
/// Only [`IncrementalSplit::Data`] is supported in this release. Diff
115+
/// planning/read remains unimplemented.
116+
pub fn to_incremental_arrow(
117+
&self,
118+
plan: &IncrementalPlan,
119+
) -> crate::Result<ArrowRecordBatchStream> {
120+
match &self.0 {
121+
TableReadKind::Paimon(read) => read.to_incremental_arrow(plan),
122+
TableReadKind::Format(_) => Err(crate::Error::Unsupported {
123+
message: "Format tables do not support incremental batch read".to_string(),
124+
}),
125+
}
126+
}
110127
}
111128

112129
#[derive(Debug, Clone)]
@@ -160,6 +177,34 @@ impl<'a> PaimonTableRead<'a> {
160177
self
161178
}
162179

180+
/// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
181+
pub fn to_incremental_arrow(
182+
&self,
183+
plan: &IncrementalPlan,
184+
) -> crate::Result<ArrowRecordBatchStream> {
185+
if plan.mode() == IncrementalScanMode::Diff {
186+
return Err(crate::Error::Unsupported {
187+
message: "Batch incremental Diff read not yet implemented".to_string(),
188+
});
189+
}
190+
191+
let mut data_splits = Vec::new();
192+
for split in plan.splits() {
193+
match split {
194+
IncrementalSplit::Data(data) => data_splits.push(data.clone()),
195+
IncrementalSplit::DiffPair { .. } => {
196+
return Err(crate::Error::UnexpectedError {
197+
message: "DiffPair appeared in non-Diff incremental plan".to_string(),
198+
source: None,
199+
});
200+
}
201+
}
202+
}
203+
// Delta / Changelog rows are read as-is from planned files (no full-table
204+
// merge against historical base versions).
205+
self.new_data_file_reader().read(&data_splits)
206+
}
207+
163208
/// Returns an [`ArrowRecordBatchStream`].
164209
pub fn to_arrow(&self, data_splits: &[DataSplit]) -> crate::Result<ArrowRecordBatchStream> {
165210
let has_primary_keys = !self.table.schema.primary_keys().is_empty();

0 commit comments

Comments
 (0)