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 f671fe4b feat(table): support incremental deduplicate compaction 
(#1253)
f671fe4b is described below

commit f671fe4b9392df5af7fd6ec2c691dfda7d4555d8
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 29 17:35:41 2026 +0800

    feat(table): support incremental deduplicate compaction (#1253)
---
 crates/paimon/src/table/compact.rs                 | 114 ++++-
 crates/paimon/src/table/compact/append.rs          |   1 +
 crates/paimon/src/table/compact/data_evolution.rs  |   1 +
 crates/paimon/src/table/compact/primary_key.rs     |  20 +-
 .../src/table/compact/primary_key/planner.rs       | 466 +++++++++++++++++++++
 crates/paimon/src/table/table_commit.rs            |  12 +-
 .../tests/pk_compact/incremental_planning.rs       | 440 +++++++++++++++++++
 crates/paimon/tests/pk_compact_test.rs             |  11 +-
 docs/src/compaction.md                             |  37 +-
 9 files changed, 1086 insertions(+), 16 deletions(-)

diff --git a/crates/paimon/src/table/compact.rs 
b/crates/paimon/src/table/compact.rs
index e1c61168..d06ebd30 100644
--- a/crates/paimon/src/table/compact.rs
+++ b/crates/paimon/src/table/compact.rs
@@ -208,6 +208,8 @@ pub struct CompactTask {
     bucket: i32,
     total_buckets: i32,
     files: Vec<DataFileMeta>,
+    #[serde(default)]
+    output_level: Option<i32>,
 }
 
 impl CompactTask {
@@ -468,6 +470,9 @@ impl CompactTask {
     }
 
     fn output_level(&self) -> i32 {
+        if let Some(output_level) = self.output_level {
+            return output_level;
+        }
         if self.is_primary_key() {
             self.files
                 .iter()
@@ -493,7 +498,21 @@ impl CompactTask {
             })
             .map(|entry| (entry.file().file_name.as_str(), entry))
             .collect::<HashMap<_, _>>();
-        if self.is_primary_key() && bucket_files.len() != self.files.len() {
+        if self.is_primary_key() && 
primary_key::planner::enabled(&self.context.schema)? {
+            let selected = primary_key::planner::select(
+                bucket_files
+                    .values()
+                    .map(|entry| entry.file().clone())
+                    .collect(),
+                self.context.schema.options(),
+            )?
+            .ok_or_else(|| invalid("No incremental PK compaction is 
eligible"))?;
+            if selected.0 != self.files || Some(selected.1) != 
self.output_level {
+                return Err(invalid(
+                    "PK compaction inputs do not match incremental run 
selection",
+                ));
+            }
+        } else if self.is_primary_key() && bucket_files.len() != 
self.files.len() {
             return Err(invalid(
                 "PK compaction must include the complete captured bucket",
             ));
@@ -522,6 +541,17 @@ impl CompactTask {
             ));
         }
         ensure_snapshot_supported(table, &self.context.snapshot)?;
+        let incremental =
+            self.is_primary_key() && 
primary_key::planner::enabled(&self.context.schema)?;
+        // Legacy results may be recovered as already committed before their
+        // captured inputs are validated. Uncommitted tasks still need the
+        // exact incremental selection in validate_inputs.
+        if self
+            .output_level
+            .is_some_and(|output_level| !incremental || output_level <= 0)
+        {
+            return Err(invalid("Invalid incremental PK compaction selection"));
+        }
         let minimum_files = if self.is_primary_key() || 
self.is_data_evolution() {
             1
         } else {
@@ -551,6 +581,79 @@ impl CompactTask {
     }
 }
 
+/// Validate published PK file changes without depending on executor task 
metadata.
+pub(super) fn validate_primary_key_changes(
+    schema: &TableSchema,
+    changes: &[ManifestEntry],
+    captured: &[ManifestEntry],
+) -> Result<()> {
+    fn group(entry: &ManifestEntry) -> (&[u8], i32, i32) {
+        (entry.partition(), entry.bucket(), entry.total_buckets())
+    }
+    let mut inputs = HashMap::<_, Vec<&ManifestEntry>>::new();
+    for entry in changes
+        .iter()
+        .filter(|entry| *entry.kind() == FileKind::Delete)
+    {
+        inputs.entry(group(entry)).or_default().push(entry);
+    }
+    let mut active = HashMap::<_, Vec<&DataFileMeta>>::new();
+    for entry in captured {
+        let key = group(entry);
+        if inputs.contains_key(&key) {
+            active.entry(key).or_default().push(entry.file());
+        }
+    }
+    let incremental = primary_key::planner::enabled(schema)?;
+    let mut output_levels = HashMap::new();
+    for (key, removed) in inputs {
+        let files = active
+            .remove(&key)
+            .unwrap_or_default()
+            .into_iter()
+            .cloned()
+            .collect::<Vec<_>>();
+        let (selected, output_level) = if incremental {
+            primary_key::planner::select(files, schema.options())?
+                .ok_or_else(|| invalid("No incremental PK compaction is 
eligible"))?
+        } else {
+            let level = files
+                .iter()
+                .map(|file| file.level)
+                .max()
+                .unwrap_or(0)
+                .max(1);
+            (files, level)
+        };
+        let expected = selected
+            .iter()
+            .map(|file| (file.file_name.as_str(), file))
+            .collect::<HashMap<_, _>>();
+        if expected.len() != removed.len()
+            || removed.iter().any(|entry| {
+                expected
+                    .get(entry.file().file_name.as_str())
+                    .is_none_or(|file| **file != *entry.file())
+            })
+        {
+            return Err(invalid(
+                "PK compaction inputs do not match captured run selection",
+            ));
+        }
+        output_levels.insert(key, output_level);
+    }
+    if changes
+        .iter()
+        .filter(|entry| *entry.kind() == FileKind::Add)
+        .any(|entry| output_levels.get(&group(entry)) != 
Some(&entry.file().level))
+    {
+        return Err(invalid(
+            "PK compaction output level does not match captured run selection",
+        ));
+    }
+    Ok(())
+}
+
 /// Validated inputs for the existing atomic snapshot publication machinery.
 pub(super) struct PreparedCompact {
     pub entries: Vec<ManifestEntry>,
@@ -603,6 +706,13 @@ pub(super) fn prepare_results(table: &Table, results: 
&[CompactResult]) -> Resul
             .iter()
             .map(|file| file.row_count as u64)
             .sum::<u64>();
+        // Missing PK task metadata must not block valid prepared file changes;
+        // their output level is checked against the captured selection at 
commit.
+        let output_level = if task.is_primary_key() {
+            task.output_level
+        } else {
+            Some(0)
+        };
         let mut output_rows = 0u64;
         for file in &result.files {
             if task.is_data_evolution() {
@@ -612,7 +722,7 @@ pub(super) fn prepare_results(table: &Table, results: 
&[CompactResult]) -> Resul
             }
             if file.schema_id != task.context.schema.id()
                 || file.file_source != Some(1)
-                || file.level != task.output_level()
+                || output_level.is_some_and(|level| file.level != level)
                 || !outputs.insert(file.file_name.clone())
                 || task.files.iter().any(|old| old.file_name == file.file_name)
             {
diff --git a/crates/paimon/src/table/compact/append.rs 
b/crates/paimon/src/table/compact/append.rs
index 58fb92f3..375b9dad 100644
--- a/crates/paimon/src/table/compact/append.rs
+++ b/crates/paimon/src/table/compact/append.rs
@@ -60,6 +60,7 @@ impl AppendCompactPlanner {
                     bucket,
                     total_buckets,
                     files,
+                    output_level: None,
                 });
             }
         }
diff --git a/crates/paimon/src/table/compact/data_evolution.rs 
b/crates/paimon/src/table/compact/data_evolution.rs
index 4e4b6f56..07ea02f6 100644
--- a/crates/paimon/src/table/compact/data_evolution.rs
+++ b/crates/paimon/src/table/compact/data_evolution.rs
@@ -193,6 +193,7 @@ impl DataEvolutionCompactPlanner {
                     bucket,
                     total_buckets,
                     files,
+                    output_level: None,
                 });
             }
         }
diff --git a/crates/paimon/src/table/compact/primary_key.rs 
b/crates/paimon/src/table/compact/primary_key.rs
index 156c7985..5f7ae111 100644
--- a/crates/paimon/src/table/compact/primary_key.rs
+++ b/crates/paimon/src/table/compact/primary_key.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 pub(super) mod lookup;
+pub(in crate::table) mod planner;
 
 use std::sync::Arc;
 
@@ -43,7 +44,7 @@ use crate::table::table_read::{
 use crate::table::{DataSplit, Plan, Table};
 use crate::Result;
 
-/// Select files using the existing primary-key bucket compaction policy.
+/// Use incremental sorted runs when merging subsets does not need history 
lookup.
 pub(super) struct PrimaryKeyCompactPlanner;
 
 impl PrimaryKeyCompactPlanner {
@@ -57,6 +58,22 @@ impl PrimaryKeyCompactPlanner {
             for file in &files {
                 validate_file(file, true)?;
             }
+            if planner::enabled(&context.schema)? {
+                if let Some((files, output_level)) =
+                    planner::select(files, context.schema.options())?
+                {
+                    tasks.push(CompactTask {
+                        context: context.clone(),
+                        task_id: tasks.len(),
+                        partition,
+                        bucket,
+                        total_buckets,
+                        files,
+                        output_level: Some(output_level),
+                    });
+                }
+                continue;
+            }
             // One positive level is already a non-overlapping sorted run,
             // including when the previous compaction rolled several files.
             let first_level = files.first().map(|file| file.level);
@@ -71,6 +88,7 @@ impl PrimaryKeyCompactPlanner {
                     bucket,
                     total_buckets,
                     files,
+                    output_level: None,
                 });
             }
         }
diff --git a/crates/paimon/src/table/compact/primary_key/planner.rs 
b/crates/paimon/src/table/compact/primary_key/planner.rs
new file mode 100644
index 00000000..f519949d
--- /dev/null
+++ b/crates/paimon/src/table/compact/primary_key/planner.rs
@@ -0,0 +1,466 @@
+// 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.
+
+//! Java UniversalCompaction run selection. Apply normal size/run triggers
+//! before forcing L0 progress, as ForceUpLevel0Compaction does, without a
+//! full-bucket override.
+
+use crate::spec::{ChangelogProducer, DataFileMeta, MergeEngine, TableSchema};
+use crate::{Error, Result};
+use std::collections::BTreeMap;
+
+pub(in crate::table::compact) fn enabled(schema: &TableSchema) -> Result<bool> 
{
+    let options = schema.core_options();
+    Ok(options.merge_engine()? == MergeEngine::Deduplicate
+        && !options.deletion_vectors_enabled()
+        && matches!(
+            options.try_changelog_producer()?,
+            ChangelogProducer::None | ChangelogProducer::Input
+        ))
+}
+
+fn number(
+    options: &std::collections::HashMap<String, String>,
+    key: &str,
+    default: i32,
+    minimum: i32,
+) -> Result<i32> {
+    let value = options
+        .get(key)
+        .map(|value| value.parse::<i32>())
+        .transpose()
+        .map_err(|_| Error::ConfigInvalid {
+            message: format!("Invalid {key}"),
+        })?
+        .unwrap_or(default);
+    if value < minimum {
+        return Err(Error::ConfigInvalid {
+            message: format!("{key} must be at least {minimum}"),
+        });
+    }
+    Ok(value)
+}
+
+pub(in crate::table::compact) fn select(
+    files: Vec<DataFileMeta>,
+    properties: &std::collections::HashMap<String, String>,
+) -> Result<Option<(Vec<DataFileMeta>, i32)>> {
+    let trigger = number(properties, "num-sorted-run.compaction-trigger", 5, 
1)?;
+    let configured_levels = number(properties, "num-levels", 
trigger.saturating_add(1), 2)?;
+    let levels = configured_levels.max(
+        files
+            .iter()
+            .map(|file| file.level.saturating_add(1))
+            .max()
+            .unwrap_or(2),
+    );
+    let ratio = number(properties, "compaction.size-ratio", 1, 0)?;
+    let amplification = number(
+        properties,
+        "compaction.max-size-amplification-percent",
+        200,
+        0,
+    )?;
+    let mut l0 = files
+        .iter()
+        .filter(|file| file.level == 0)
+        .cloned()
+        .collect::<Vec<_>>();
+    l0.sort_by(|a, b| {
+        b.max_sequence_number
+            .cmp(&a.max_sequence_number)
+            .then_with(|| a.min_sequence_number.cmp(&b.min_sequence_number))
+            .then_with(|| {
+                a.creation_time
+                    .unwrap_or_default()
+                    .cmp(&b.creation_time.unwrap_or_default())
+            })
+            .then_with(|| a.file_name.cmp(&b.file_name))
+    });
+    let mut positive = BTreeMap::<i32, Vec<DataFileMeta>>::new();
+    for file in files.into_iter().filter(|file| file.level > 0) {
+        positive.entry(file.level).or_default().push(file);
+    }
+    let l0_count = l0.len();
+    let mut runs = l0
+        .into_iter()
+        .map(|file| (0, vec![file]))
+        .collect::<Vec<_>>();
+    for (level, mut files) in positive {
+        files.sort_by(|a, b| a.file_name.cmp(&b.file_name));
+        runs.push((level, files));
+    }
+    if runs.is_empty() {
+        return Ok(None);
+    }
+    let sizes = runs
+        .iter()
+        .map(|(_, files)| {
+            files
+                .iter()
+                .map(|file| file.file_size as u128)
+                .sum::<u128>()
+        })
+        .collect::<Vec<_>>();
+    let mut count = None;
+    if runs.len() >= trigger as usize {
+        let additional = sizes[..sizes.len() - 1].iter().sum::<u128>();
+        if additional * 100 > amplification as u128 * sizes[sizes.len() - 1] {
+            count = Some(runs.len());
+        } else {
+            count = pick_for_size_ratio(&runs, &sizes, ratio, 1, false);
+        }
+        if count.is_none() && runs.len() > trigger as usize {
+            count = pick_for_size_ratio(
+                &runs,
+                &sizes,
+                ratio,
+                runs.len() - trigger as usize + 1,
+                false,
+            );
+        }
+    }
+    if count.is_none() && l0_count > 0 {
+        count = pick_for_size_ratio(&runs, &sizes, ratio, l0_count, true);
+    }
+    let Some(count) = count else {
+        return Ok(None);
+    };
+    let output_level = if count == runs.len() {
+        levels - 1
+    } else {
+        (runs[count].0 - 1).max(1)
+    };
+    let selected = runs[..count]
+        .iter()
+        .flat_map(|(_, files)| files.clone())
+        .collect::<Vec<_>>();
+    if selected.len() == 1 && selected[0].level == output_level {
+        return Ok(None);
+    }
+    Ok(Some((selected, output_level)))
+}
+
+fn pick_for_size_ratio(
+    runs: &[(i32, Vec<DataFileMeta>)],
+    sizes: &[u128],
+    ratio: i32,
+    mut count: usize,
+    force: bool,
+) -> Option<usize> {
+    let mut size = sizes[..count].iter().sum::<u128>();
+    let mut triggered = force || count > 1;
+    while count < runs.len() {
+        // Match Java's double arithmetic, including threshold rounding for
+        // large runs, rather than comparing exact integer cross-products.
+        if size as f64 * (100.0 + f64::from(ratio)) / 100.0 < sizes[count] as 
f64
+            && (!triggered || runs[count].0 > 1)
+        {
+            break;
+        }
+        size += sizes[count];
+        count += 1;
+        triggered = true;
+    }
+    triggered.then_some(count)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::stats::BinaryTableStats;
+    use std::collections::HashMap;
+    fn file(name: &str, level: i32, size: i64) -> DataFileMeta {
+        DataFileMeta {
+            file_name: name.into(),
+            file_size: size,
+            row_count: 1,
+            min_key: vec![],
+            max_key: vec![],
+            key_stats: BinaryTableStats::empty(),
+            value_stats: BinaryTableStats::empty(),
+            min_sequence_number: 0,
+            max_sequence_number: 1,
+            schema_id: 0,
+            level,
+            extra_files: vec![],
+            creation_time: None,
+            delete_row_count: Some(0),
+            embedded_index: None,
+            file_source: Some(0),
+            value_stats_cols: None,
+            external_path: None,
+            first_row_id: None,
+            write_cols: None,
+            column_max_sequence_numbers: None,
+        }
+    }
+    #[test]
+    fn java_force_l0_stops_before_a_large_higher_level_and_outputs_below_it() {
+        let (selected, output_level) = select(
+            vec![file("new", 0, 1), file("anchor", 5, 1000)],
+            &HashMap::new(),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(
+            selected
+                .iter()
+                .map(|file| file.file_name.as_str())
+                .collect::<Vec<_>>(),
+            vec!["new"]
+        );
+        assert_eq!(output_level, 4);
+        let (selected, output_level) = select(
+            vec![
+                file("new", 0, 1),
+                file("l1", 1, 100),
+                file("anchor", 5, 10000),
+            ],
+            &HashMap::new(),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(
+            selected.len(),
+            2,
+            "Java always consumes L1 rather than outputting L0"
+        );
+        assert_eq!(output_level, 4);
+    }
+    #[test]
+    fn 
positive_run_count_and_size_amplification_trigger_incremental_maintenance() {
+        let options = HashMap::from([
+            ("num-sorted-run.compaction-trigger".into(), "2".into()),
+            ("num-levels".into(), "6".into()),
+        ]);
+        assert!(
+            select(vec![file("small", 3, 1), file("anchor", 5, 1000)], 
&options)
+                .unwrap()
+                .is_none()
+        );
+        let (selected, output_level) = select(
+            vec![
+                file("small", 3, 1),
+                file("medium", 4, 10),
+                file("anchor", 5, 1000),
+            ],
+            &options,
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(selected.len(), 2);
+        assert_eq!(output_level, 4);
+        let (selected, output_level) = select(
+            vec![
+                file("large", 1, 1000),
+                file("other", 3, 1000),
+                file("old", 5, 100),
+            ],
+            &options,
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(selected.len(), 3);
+        assert_eq!(output_level, 5);
+    }
+
+    #[test]
+    fn selection_is_stable_and_keeps_each_positive_run_together() {
+        let mut older = file("older-l0", 0, 1);
+        older.max_sequence_number = 10;
+        let mut newer = file("newer-l0", 0, 1);
+        newer.max_sequence_number = 20;
+        let files = vec![
+            file("anchor", 5, 100000),
+            file("b", 1, 100),
+            older,
+            file("a", 1, 100),
+            newer,
+        ];
+        let expected = select(files.clone(), 
&HashMap::new()).unwrap().unwrap();
+        assert_eq!(
+            expected
+                .0
+                .iter()
+                .map(|file| file.file_name.as_str())
+                .collect::<Vec<_>>(),
+            vec!["newer-l0", "older-l0", "a", "b"]
+        );
+        assert_eq!(expected.1, 4);
+        let reversed = files.into_iter().rev().collect();
+        assert_eq!(
+            select(reversed, &HashMap::new()).unwrap().unwrap(),
+            expected
+        );
+        let mut ties = vec![];
+        for (name, minimum, time) in [("a-later", 2, 2), ("z-earlier", 2, 1), 
("z-minimum", 1, 2)] {
+            let mut row = file(name, 0, 1);
+            row.min_sequence_number = minimum;
+            row.max_sequence_number = 20;
+            row.creation_time = chrono::DateTime::from_timestamp_millis(time);
+            ties.push(row);
+        }
+        let selected = select(ties, &HashMap::new()).unwrap().unwrap();
+        assert_eq!(
+            selected
+                .0
+                .iter()
+                .map(|file| file.file_name.as_str())
+                .collect::<Vec<_>>(),
+            vec!["z-minimum", "z-earlier", "a-later"]
+        );
+    }
+
+    #[test]
+    fn size_ratio_and_amplification_use_java_threshold_boundaries() {
+        let options = 
HashMap::from([("num-sorted-run.compaction-trigger".into(), "3".into())]);
+        for (middle, all) in [(199, false), (200, true)] {
+            let selected = select(
+                vec![
+                    file("small", 2, 1),
+                    file("middle", 3, middle),
+                    file("old", 5, 100),
+                ],
+                &options,
+            )
+            .unwrap();
+            if all {
+                assert_eq!(selected.unwrap().0.len(), 3);
+            } else {
+                assert!(selected.is_none());
+            }
+        }
+        for (first, merge) in [(199, false), (200, true)] {
+            let selected = select(
+                vec![
+                    file("first", 2, first),
+                    file("second", 3, 202),
+                    file("old", 5, 100000),
+                ],
+                &options,
+            )
+            .unwrap();
+            if merge {
+                let (files, output_level) = selected.unwrap();
+                assert_eq!(files.len(), 2);
+                assert_eq!(output_level, 4);
+            } else {
+                assert!(selected.is_none());
+            }
+        }
+        // Arithmetic must not overflow when byte sizes exceed i64 percentages.
+        let (files, output_level) = select(
+            vec![file("first", 0, i64::MAX), file("old", 5, i64::MAX)],
+            &HashMap::new(),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(files.len(), 2);
+        assert_eq!(output_level, 5);
+    }
+
+    #[test]
+    fn 
singletons_empty_inputs_and_existing_levels_do_not_force_a_full_rewrite() {
+        assert!(select(vec![], &HashMap::new()).unwrap().is_none());
+        assert!(select(vec![file("old", 5, 100)], &HashMap::new())
+            .unwrap()
+            .is_none());
+        let options = HashMap::from([("num-levels".into(), "3".into())]);
+        let (_, output_level) = select(vec![file("new", 0, 1)], 
&options).unwrap().unwrap();
+        assert_eq!(output_level, 2);
+        let (_, output_level) = select(vec![file("new", 0, 100), file("old", 
8, 1)], &options)
+            .unwrap()
+            .unwrap();
+        assert_eq!(output_level, 8, "existing higher levels remain valid");
+    }
+
+    #[test]
+    fn invalid_run_policy_options_are_reported_before_selection() {
+        for (name, bad, minimum) in [
+            ("num-sorted-run.compaction-trigger", "0", "1"),
+            ("num-levels", "1", "2"),
+            ("compaction.size-ratio", "-1", "0"),
+            ("compaction.max-size-amplification-percent", "-1", "0"),
+        ] {
+            for value in [bad, "invalid", "2147483648"] {
+                let options = HashMap::from([(name.into(), value.into())]);
+                let error = select(vec![file("new", 0, 1)], 
&options).unwrap_err();
+                assert!(matches!(error, Error::ConfigInvalid { .. }));
+                assert!(error.to_string().contains(name));
+            }
+            let options = HashMap::from([(name.into(), minimum.into())]);
+            assert!(select(vec![file("new", 0, 1)], 
&options).unwrap().is_some());
+        }
+    }
+
+    #[test]
+    fn normal_maintenance_takes_precedence_over_forcing_l0() {
+        let (files, output_level) = select(
+            vec![
+                file("new", 0, 1),
+                file("l2", 2, 1000),
+                file("l3", 3, 1000),
+                file("l4", 4, 1000),
+                file("old", 5, 100),
+            ],
+            &HashMap::new(),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(
+            files.len(),
+            5,
+            "space amplification must run even when L0 exists"
+        );
+        assert_eq!(output_level, 5);
+
+        let options = 
HashMap::from([("num-sorted-run.compaction-trigger".into(), "2".into())]);
+        let (files, output_level) = select(
+            vec![
+                file("new", 0, 1),
+                file("small", 3, 100),
+                file("old", 5, 100000),
+            ],
+            &options,
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(
+            files.len(),
+            2,
+            "run-count maintenance must precede forced L0 progress"
+        );
+        assert_eq!(output_level, 4);
+    }
+
+    #[test]
+    fn large_run_size_ratio_matches_java_double_threshold() {
+        let options = 
HashMap::from([("num-sorted-run.compaction-trigger".into(), "2".into())]);
+        let (files, output_level) = select(
+            vec![
+                file("newer", 2, 100_000_000_000_099),
+                file("older", 5, 101_000_000_000_100),
+            ],
+            &options,
+        )
+        .unwrap()
+        .expect("Java rounds the 1% threshold up to the older run's size");
+        assert_eq!(files.len(), 2);
+        assert_eq!(output_level, 5);
+    }
+}
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 9d6c4187..007fb1b7 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -1961,6 +1961,13 @@ impl TableCommit {
                         .iter()
                         .map(|e| (e.partition(), e.bucket()))
                         .collect::<HashSet<_>>();
+                    if !self.table.schema().primary_keys().is_empty() {
+                        super::compact::validate_primary_key_changes(
+                            self.table.schema(),
+                            entries,
+                            &captured_files,
+                        )?;
+                    }
                     let deleted_ids = deletes
                         .iter()
                         .map(|entry| commit_file_identifier(entry))
@@ -1983,8 +1990,7 @@ impl TableCommit {
                         .iter()
                         .filter(|e| {
                             buckets.contains(&(e.partition(), e.bucket()))
-                                && (!self.data_evolution_enabled
-                                    || 
deleted_ids.contains(&commit_file_identifier(e)))
+                                && 
deleted_ids.contains(&commit_file_identifier(e))
                         })
                         .map(|entry| (commit_file_identifier(entry), entry))
                         .collect::<HashMap<_, _>>();
@@ -2002,7 +2008,7 @@ impl TableCommit {
                             message: if self.data_evolution_enabled {
                                 "Data-evolution compaction inputs must match 
the captured row-ID groups"
                             } else {
-                                "PK compaction must include the complete 
captured bucket"
+                                "PK compaction must match its captured 
incremental inputs"
                             }.into(),
                             source: None,
                         });
diff --git a/crates/paimon/tests/pk_compact/incremental_planning.rs 
b/crates/paimon/tests/pk_compact/incremental_planning.rs
new file mode 100644
index 00000000..af2be93b
--- /dev/null
+++ b/crates/paimon/tests/pk_compact/incremental_planning.rs
@@ -0,0 +1,440 @@
+// 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 compact_once(table: &Table) -> Vec<CompactResult> {
+    let builder = table.new_compact_builder();
+    let plan = builder.new_planner().unwrap().plan().await.unwrap();
+    let results = execute(table, &plan).await;
+    builder
+        .new_commit()
+        .unwrap()
+        .commit_compact(results.clone())
+        .await
+        .unwrap();
+    results
+}
+
+async fn active_files(table: &Table) -> Vec<paimon::spec::DataFileMeta> {
+    let scan = table
+        .new_read_builder()
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await
+        .unwrap();
+    scan.splits()
+        .iter()
+        .flat_map(|split| split.data_files())
+        .cloned()
+        .collect()
+}
+
+#[tokio::test]
+async fn 
incremental_deduplicate_keeps_large_history_and_preserves_rows_and_snapshots() {
+    for producer in ["none", "input"] {
+        let table = table(&[("bucket", "1"), ("changelog-producer", 
producer)]).await;
+        let original = (0..1000).map(|key| (key, key)).collect::<Vec<_>>();
+        write_batch(
+            &table,
+            &make_batch((0..1000).collect(), (0..1000).collect()),
+        )
+        .await;
+        let first = compact_once(&table).await;
+        let anchor = first[0].files()[0].clone();
+        assert_eq!(anchor.level, 5);
+        let snapshot = table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let historical = table.copy_with_pinned_snapshot(Some(&snapshot));
+        for key in [42, 43] {
+            write_batch(
+                &table,
+                &make_batch_with_kinds(
+                    vec![key, key + 100],
+                    vec![10000 + key, key + 100],
+                    vec![0, 3],
+                ),
+            )
+            .await;
+            let before = rows(&table).await;
+            let before_sequences = sequences(&table).await;
+            let builder = table.new_compact_builder();
+            let plan = builder.new_planner().unwrap().plan().await.unwrap();
+            assert_eq!(plan.tasks().len(), 1);
+            assert!(plan.tasks()[0]
+                .files()
+                .iter()
+                .all(|file| file.file_name != anchor.file_name));
+            let results = execute(&table, &plan).await;
+            assert!(results[0].files().iter().all(|file| file.level == 4));
+            builder
+                .new_commit()
+                .unwrap()
+                .commit_compact(results)
+                .await
+                .unwrap();
+            assert!(active_files(&table).await.contains(&anchor));
+            assert_eq!(rows(&table).await, before);
+            assert_eq!(sequences(&table).await, before_sequences);
+            assert_eq!(rows(&historical).await, original);
+            assert!(builder
+                .new_planner()
+                .unwrap()
+                .plan()
+                .await
+                .unwrap()
+                .tasks()
+                .is_empty());
+        }
+        let actual = rows(&table).await;
+        assert_eq!(actual.len(), 998);
+        assert!(actual.contains(&(42, 10042)) && actual.contains(&(43, 
10043)));
+        assert!(!actual.iter().any(|(key, _)| [142, 143].contains(key)));
+    }
+}
+
+fn sequenced_batch(ids: Vec<i32>, values: Vec<i32>, seq: i32, kind: i8) -> 
RecordBatch {
+    let count = ids.len();
+    RecordBatch::try_from_iter([
+        (
+            "id",
+            std::sync::Arc::new(Int32Array::from(ids)) as 
arrow_array::ArrayRef,
+        ),
+        (
+            "value",
+            std::sync::Arc::new(Int32Array::from(values)) as 
arrow_array::ArrayRef,
+        ),
+        (
+            "seq",
+            std::sync::Arc::new(Int32Array::from(vec![seq; count])) as 
arrow_array::ArrayRef,
+        ),
+        (
+            "_VALUE_KIND",
+            std::sync::Arc::new(arrow_array::Int8Array::from(vec![kind; 
count]))
+                as arrow_array::ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+#[tokio::test]
+async fn incremental_deduplicate_keeps_user_sequence_winners_and_tombstones() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .column("seq", DataType::Int(IntType::new()))
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("sequence.field", "seq")
+        .build()
+        .unwrap();
+    let table = table_with_schema(TableSchema::new(0, &schema)).await;
+    write_batch(
+        &table,
+        &sequenced_batch((0..1000).collect(), (0..1000).collect(), 100, 0),
+    )
+    .await;
+    let anchor = compact_once(&table).await[0].files()[0].clone();
+    for (sequence, kind, expected) in [
+        (50, 0, Some(42)),
+        (150, 0, Some(150)),
+        (200, 3, None),
+        (180, 0, None),
+        (300, 0, Some(300)),
+    ] {
+        write_batch(
+            &table,
+            &sequenced_batch(vec![42], vec![sequence], sequence, kind),
+        )
+        .await;
+        let before = rows(&table).await;
+        let results = compact_once(&table).await;
+        assert!(!results.is_empty());
+        assert!(results
+            .iter()
+            .flat_map(|result| result.files())
+            .all(|file| file.level == 4));
+        assert_eq!(rows(&table).await, before);
+        assert_eq!(
+            before
+                .iter()
+                .find(|(key, _)| *key == 42)
+                .map(|(_, value)| *value),
+            expected
+        );
+        assert!(active_files(&table).await.contains(&anchor));
+    }
+}
+
+#[tokio::test]
+async fn incremental_selection_is_validated_for_execution_and_commit() {
+    let table = table(&[("bucket", "1")]).await;
+    write_batch(
+        &table,
+        &make_batch((0..1000).collect(), (0..1000).collect()),
+    )
+    .await;
+    let anchor = compact_once(&table).await[0].files()[0].clone();
+    write_batch(&table, &make_batch(vec![42], vec![4200])).await;
+    write_batch(&table, &make_batch(vec![43], vec![4300])).await;
+    let builder = table.new_compact_builder();
+    let plan = builder.new_planner().unwrap().plan().await.unwrap();
+    let captured = plan.snapshot_id();
+    let results = execute(&table, &plan).await;
+    let task_json = serde_json::to_value(&plan.tasks()[0]).unwrap();
+    assert_eq!(task_json["output_level"], 4);
+    assert!(task_json.get("primary_key").is_none());
+    for mutation in [
+        "omit-input",
+        "include-history",
+        "output-level",
+        "legacy-output-level",
+    ] {
+        let mutate = |task: &mut serde_json::Value| match mutation {
+            "omit-input" => {
+                task["files"].as_array_mut().unwrap().pop();
+            }
+            "include-history" => task["files"]
+                .as_array_mut()
+                .unwrap()
+                .push(serde_json::to_value(&anchor).unwrap()),
+            "output-level" => task["output_level"] = 5.into(),
+            "legacy-output-level" => {
+                task.as_object_mut().unwrap().remove("output_level");
+            }
+            _ => unreachable!(),
+        };
+        let mut encoded = task_json.clone();
+        mutate(&mut encoded);
+        let task: CompactTask = serde_json::from_value(encoded).unwrap();
+        assert!(
+            builder
+                .new_executor()
+                .unwrap()
+                .execute(&task)
+                .await
+                .is_err(),
+            "execute {mutation}"
+        );
+        let mut encoded = serde_json::to_value(&results).unwrap();
+        mutate(&mut encoded[0]["task"]);
+        if mutation == "output-level" {
+            for file in encoded[0]["files"].as_array_mut().unwrap() {
+                file["_LEVEL"] = 5.into();
+            }
+        }
+        if mutation == "legacy-output-level" {
+            for file in encoded[0]["files"].as_array_mut().unwrap() {
+                file["_LEVEL"] = 1.into();
+            }
+        }
+        let changed: Vec<CompactResult> = 
serde_json::from_value(encoded).unwrap();
+        if matches!(mutation, "output-level" | "legacy-output-level") {
+            let expected = if mutation == "output-level" { 5 } else { 1 };
+            assert!(changed[0].files().iter().all(|file| file.level == 
expected));
+        }
+        let error = builder
+            .new_commit()
+            .unwrap()
+            .commit_compact(changed)
+            .await
+            .expect_err(&format!("commit {mutation}"));
+        if mutation == "legacy-output-level" {
+            assert!(error.to_string().contains("output level"), "{error}");
+        }
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot_id()
+                .await
+                .unwrap(),
+            captured
+        );
+    }
+    builder
+        .new_commit()
+        .unwrap()
+        .commit_compact(results)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&table).await.iter().find(|(key, _)| *key == 42),
+        Some(&(42, 4200))
+    );
+    assert!(active_files(&table).await.contains(&anchor));
+}
+
+#[tokio::test]
+async fn 
incremental_compaction_preserves_concurrent_l0_appends_and_retry_identity() {
+    let table = table(&[("bucket", "1")]).await;
+    write_batch(
+        &table,
+        &make_batch((0..1000).collect(), (0..1000).collect()),
+    )
+    .await;
+    let anchor = compact_once(&table).await[0].files()[0].clone();
+    write_batch(&table, &make_batch(vec![42], vec![4200])).await;
+    let builder = table.new_compact_builder();
+    let plan = builder.new_planner().unwrap().plan().await.unwrap();
+    let results = execute(&table, &plan).await;
+    write_batch(&table, &make_batch(vec![42], vec![4201])).await;
+    let committer = builder.new_commit().unwrap();
+    committer.commit_compact(results.clone()).await.unwrap();
+    let snapshot = table
+        .snapshot_manager()
+        .get_latest_snapshot_id()
+        .await
+        .unwrap();
+    committer.commit_compact(results).await.unwrap();
+    assert_eq!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap(),
+        snapshot
+    );
+    assert_eq!(
+        rows(&table).await.iter().find(|(key, _)| *key == 42),
+        Some(&(42, 4201))
+    );
+    assert!(active_files(&table).await.contains(&anchor));
+    compact_once(&table).await;
+    assert_eq!(
+        rows(&table).await.iter().find(|(key, _)| *key == 42),
+        Some(&(42, 4201))
+    );
+}
+
+#[tokio::test]
+async fn order_sensitive_aggregation_keeps_its_history_in_the_merge() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .column("seq", DataType::Int(IntType::new()))
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("sequence.field", "seq")
+        .option("merge-engine", "aggregation")
+        .option("fields.value.aggregate-function", "first_value")
+        .build()
+        .unwrap();
+    let table = table_with_schema(TableSchema::new(0, &schema)).await;
+    write_batch(
+        &table,
+        &sequenced_batch((0..1000).collect(), vec![20; 1000], 20, 0),
+    )
+    .await;
+    let anchor = compact_once(&table).await[0].files()[0].clone();
+    write_batch(&table, &sequenced_batch(vec![42], vec![10], 10, 0)).await;
+    write_batch(&table, &sequenced_batch(vec![42], vec![30], 30, 0)).await;
+    let before = rows(&table).await;
+    assert_eq!(before.iter().find(|(key, _)| *key == 42), Some(&(42, 10)));
+    let builder = table.new_compact_builder();
+    let plan = builder.new_planner().unwrap().plan().await.unwrap();
+    assert!(plan.tasks()[0].files().contains(&anchor));
+    let results = execute(&table, &plan).await;
+    builder
+        .new_commit()
+        .unwrap()
+        .commit_compact(results)
+        .await
+        .unwrap();
+    assert_eq!(rows(&table).await, before);
+}
+
+#[tokio::test]
+async fn legacy_results_validate_file_changes_and_recover_idempotently() {
+    for keep_history in [false, true] {
+        let table = table(&[("bucket", "1")]).await;
+        let initial = if keep_history {
+            make_batch((0..1000).collect(), (0..1000).collect())
+        } else {
+            make_batch(vec![1], vec![10])
+        };
+        write_batch(&table, &initial).await;
+        compact_once(&table).await;
+        let update = if keep_history {
+            make_batch(vec![42], vec![4200])
+        } else {
+            make_batch((0..1000).collect(), (0..1000).collect())
+        };
+        write_batch(&table, &update).await;
+        let mut expected = (0..1000).map(|key| (key, key)).collect::<Vec<_>>();
+        if keep_history {
+            expected[42].1 = 4200;
+        }
+        assert_eq!(rows(&table).await, expected);
+        let builder = table.new_compact_builder();
+        let plan = builder.new_planner().unwrap().plan().await.unwrap();
+        assert_eq!(
+            plan.tasks()[0].files().len(),
+            if keep_history { 1 } else { 2 }
+        );
+        let results = execute(&table, &plan).await;
+        assert!(results[0]
+            .files()
+            .iter()
+            .all(|file| file.level == if keep_history { 4 } else { 5 }));
+        // Older tasks cannot execute without their output level. Prepared 
results
+        // can still be published when their file changes match the captured 
policy.
+        let mut legacy = serde_json::to_value(&results).unwrap();
+        legacy[0]["task"]
+            .as_object_mut()
+            .unwrap()
+            .remove("output_level");
+        let legacy: Vec<CompactResult> = 
serde_json::from_value(legacy).unwrap();
+        let task =
+            
serde_json::from_value(serde_json::to_value(&legacy).unwrap()[0]["task"].clone())
+                .unwrap();
+        assert!(builder
+            .new_executor()
+            .unwrap()
+            .execute(&task)
+            .await
+            .is_err());
+        let committer = builder.new_commit().unwrap();
+        committer.commit_compact(legacy.clone()).await.unwrap();
+        let published = table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap();
+        committer.commit_compact(results).await.unwrap();
+        let snapshot = table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap();
+        assert_eq!(snapshot, published);
+        committer.commit_compact(legacy).await.unwrap();
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot_id()
+                .await
+                .unwrap(),
+            snapshot
+        );
+        assert_eq!(rows(&table).await, expected);
+    }
+}
diff --git a/crates/paimon/tests/pk_compact_test.rs 
b/crates/paimon/tests/pk_compact_test.rs
index a79323a9..4021bf42 100644
--- a/crates/paimon/tests/pk_compact_test.rs
+++ b/crates/paimon/tests/pk_compact_test.rs
@@ -23,6 +23,9 @@ mod engine_tests;
 #[path = "pk_compact/lookup_changelog.rs"]
 mod lookup_changelog_tests;
 
+#[path = "pk_compact/incremental_planning.rs"]
+mod incremental_planning_tests;
+
 #[path = "pk_compact/dynamic_bucket.rs"]
 mod dynamic_tests;
 
@@ -151,7 +154,7 @@ async fn 
pk_compaction_rejects_concurrent_overlapping_positive_level_files() {
             .unwrap();
         let mut messages = writer.prepare_commit().await.unwrap();
         for file in &mut messages[0].new_files {
-            file.level = 1;
+            file.level = results[0].files()[0].level;
         }
         write.new_commit().commit(messages).await.unwrap();
         let result = 
builder.new_commit().unwrap().commit_compact(results).await;
@@ -283,7 +286,7 @@ async fn 
pk_compaction_merges_versions_and_preserves_sequences_deletes_and_histo
         let results = execute(&table, &plan).await;
         assert_eq!(results[0].files().len(), 1, "{format}");
         let file = &results[0].files()[0];
-        assert_eq!(file.level, 1);
+        assert_eq!(file.level, 5);
         assert_eq!(file.delete_row_count, Some(1));
         assert_eq!((file.min_sequence_number, file.max_sequence_number), (2, 
5));
         assert_eq!(file.file_source, Some(1));
@@ -506,7 +509,7 @@ async fn 
pk_compaction_rolls_ordered_files_and_rebuilds_indexes_with_level_stats
     let plan = builder.new_planner().unwrap().plan().await.unwrap();
     let results = execute(&table, &plan).await;
     assert_eq!(results[0].files().len(), 3);
-    assert!(results[0].files().iter().all(|file| file.level == 1
+    assert!(results[0].files().iter().all(|file| file.level == 5
         && file.row_count <= 2
         && (file.embedded_index.is_some() || !file.extra_files.is_empty())
         && !file.value_stats.null_counts().is_empty()));
@@ -582,7 +585,7 @@ async fn 
pk_compaction_refuses_partial_buckets_and_unsupported_layouts() {
         .await
         .unwrap_err()
         .to_string()
-        .contains("complete captured bucket"));
+        .contains("incremental run selection"));
     let results = execute(&table, &plan).await;
     let mut encoded = serde_json::to_value(&results).unwrap();
     encoded[0]["task"]["files"].as_array_mut().unwrap().pop();
diff --git a/docs/src/compaction.md b/docs/src/compaction.md
index 2f8121f2..d2ac5ab9 100644
--- a/docs/src/compaction.md
+++ b/docs/src/compaction.md
@@ -115,12 +115,37 @@ snapshot expiration and storage reclamation are separate 
maintenance work.
 The same API compacts primary-key tables with `deduplicate`,
 `first-row`, `partial-update` or `aggregation` merge engines. Changelog 
producers
 `none`, `input` and `lookup` are supported; `first-row` supports `none` or 
`lookup`.
-The planner includes every active file of each selected partition/bucket,
-including large files. Append size and count selection options do not split PK
-buckets. A single level-0 file is also eligible. A bucket whose files already 
form one sorted
-run at a single positive level needs no rewrite, including when the previous
-compaction rolled multiple output files. Output is key ordered at the highest 
input level, or level 1
-when all inputs are level 0.
+For `deduplicate` with `none` or `input`, the planner chooses incremental 
sorted
+runs using Java's universal-compaction size and count policy. Each L0 file is a
+run; all files at one positive level form a single run. Normal size and 
run-count
+triggers take precedence. If they select no work, explicit compaction forces
+progress on every L0 run and absorbs adjacent runs according to their sizes. A
+large higher-level run can remain in place while smaller runs are rewritten.
+Without L0, positive-level maintenance requires the configured run-count and
+size triggers. A single positive run needs no rewrite, even when it spans
+several files.
+
+The policy uses `num-sorted-run.compaction-trigger` (default `5`), `num-levels`
+(default trigger plus one), `compaction.size-ratio` (default `1` percent), and
+`compaction.max-size-amplification-percent` (default `200`). When selected runs
+reach the oldest run, output goes to the highest configured or existing level;
+otherwise it goes immediately below the next unselected run. Explicit full
+compaction is not exposed. Outputs retain winning retracts, so older values
+in untouched files do not reappear after a delete or out-of-order update.
+
+`lookup`, `first-row`, `partial-update` and `aggregation` retain their current
+selection behavior: every active file in a selected bucket participates, and
+output uses the highest input level or level 1 for all-L0 inputs. Their state
+and changelog generation must see history before incremental selection can be
+enabled. Append size and count options do not control PK selection.
+
+Tasks capture their selected runs and output level. Executors validate the task
+against the captured snapshot. Committers independently recompute the selection
+from that snapshot and validate the files being removed and the output levels.
+Older serialized deduplicate tasks without an output level must be replanned
+before execution. Prepared results can still be published if their file changes
+match the captured selection; already committed results remain recoverable
+through the existing idempotent retry path.
 
 Compaction uses each engine's existing merge function and persists its merge
 state, including original sequence metadata and synthesized DELETE records:

Reply via email to