This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 03a9fa2cdb [core][spark][flink] Support TRUNCATE TABLE on format
tables (#9330)
03a9fa2cdb is described below
commit 03a9fa2cdbe7c473e46d1e7ad4595780dd62d0d0
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Aug 21 21:53:41 2026 +0800
[core][spark][flink] Support TRUNCATE TABLE on format tables (#9330)
---
docs/docs/flink/sql-write.mdx | 7 +
docs/docs/spark/sql-write.md | 10 +
.../paimon/table/format/FormatTableCommit.java | 204 +++++++++++++--
.../apache/paimon/table/sink/BatchTableCommit.java | 6 +-
.../format/FormatTableCommitStatisticsTest.java | 281 +++++++++++++++++++++
.../paimon/table/format/FormatTableCommitTest.java | 211 ++++++++++++++++
.../paimon/flink/sink/FlinkFormatTableSink.java | 35 +++
.../paimon/flink/sink/FlinkFormatTableSink.java | 35 +++
.../paimon/flink/sink/FlinkFormatTableSink.java | 79 ++----
...ableSink.java => FlinkFormatTableSinkBase.java} | 12 +-
.../paimon/flink/source/FormatTableITCase.java | 94 +++++--
.../paimon/spark/PaimonPartitionManagement.scala | 8 +-
.../paimon/spark/format/PaimonFormatTable.scala | 96 ++++++-
.../spark/format/CatalogManagedPartitionTest.scala | 23 ++
.../FormatTablePartitionManagementTest.scala | 110 +++++++-
.../CatalogManagedPartitionMsckRepairTest.scala | 33 +++
.../paimon/spark/sql/FormatTableTestBase.scala | 51 ++++
17 files changed, 1169 insertions(+), 126 deletions(-)
diff --git a/docs/docs/flink/sql-write.mdx b/docs/docs/flink/sql-write.mdx
index 8f1d8e3cfb..aea1ba870c 100644
--- a/docs/docs/flink/sql-write.mdx
+++ b/docs/docs/flink/sql-write.mdx
@@ -142,6 +142,13 @@ TRUNCATE TABLE my_table;
</Tabs>
+On a Format Table read through Paimon (`format-table.implementation = paimon`,
the default),
+`TRUNCATE TABLE` deletes the data files and keeps the partitions: their
directories remain, and
+with `metastore.partitioned-table = true` so do their catalog registrations,
whose statistics are
+replaced with zero. That setting also makes the catalog the answer to which
partitions the table
+has, so truncating empties those and leaves an unregistered directory alone.
Flink has no
+`TRUNCATE TABLE ... PARTITION`; use Spark's.
+
## Purging Partitions
Currently, Paimon supports two ways to purge partitions.
diff --git a/docs/docs/spark/sql-write.md b/docs/docs/spark/sql-write.md
index 1cc6c0bc69..5ac34f0834 100644
--- a/docs/docs/spark/sql-write.md
+++ b/docs/docs/spark/sql-write.md
@@ -129,8 +129,18 @@ The `TRUNCATE TABLE` statement removes all the rows from a
table or partition(s)
```sql
TRUNCATE TABLE my_table;
+TRUNCATE TABLE my_table PARTITION (dt = '2025-01-01');
```
+On a Format Table read through Paimon (`format-table.implementation = paimon`,
the default),
+`TRUNCATE TABLE` deletes the data files of the table or of the named
partitions and keeps the
+partitions: their directories remain, and with `metastore.partitioned-table =
true` so do their
+catalog registrations, so `SHOW PARTITIONS` returns what it returned before.
That setting also
+makes the catalog the answer to which partitions the table has, so truncating
empties those, leaves
+a directory still waiting for `MSCK REPAIR TABLE` alone, and replaces their
statistics with zero. A
+spec that names only some of the partition keys empties the partitions it
covers; a complete spec
+the table does not have is an error.
+
## Update Table
Updates the column values for the rows that match a predicate. When no
predicate is provided, update the column values for all rows.
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
index ebf5c39c50..aec5f73e15 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
@@ -30,11 +30,13 @@ import org.apache.paimon.fs.TwoPhaseOutputStream;
import org.apache.paimon.metrics.MetricRegistry;
import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.stats.Statistics;
import org.apache.paimon.table.sink.BatchTableCommit;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.sink.TableCommit;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.PartitionPathUtils;
import org.slf4j.Logger;
@@ -53,6 +55,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import static
org.apache.paimon.table.format.FormatBatchWriteBuilder.validateStaticPartition;
@@ -199,7 +202,11 @@ public class FormatTableCommit implements BatchTableCommit
{
}
if (reportsStatistics) {
reportPartitions(
- partitionSpecs, statisticsByPartition,
clearedPartitionPaths, commitTime);
+ partitionSpecs,
+ statisticsByPartition,
+ clearedPartitionPaths,
+ commitTime,
+ overwrite);
} else if (partitionManager != null && !partitionSpecs.isEmpty()) {
// Concurrent writers may touch the same partition, so
registration ignores the
// ones that already exist rather than failing the commit.
@@ -234,26 +241,20 @@ public class FormatTableCommit implements
BatchTableCommit {
* Registers the partitions this commit touched, carrying the statistics
of what it wrote. A
* static prefix overwrite also empties partitions it writes nothing to;
those report an exact
* zero and are registered with the rest, since a statistic can only be
reported for a partition
- * its own request registers.
+ * its own request registers. A truncation writes nothing and reports
every partition it
+ * emptied.
*/
private void reportPartitions(
Set<Map<String, String>> writtenPartitionSpecs,
Map<Map<String, String>, PartitionStatistics>
statisticsByPartition,
Set<Path> clearedPartitionPaths,
- long commitTime) {
+ long commitTime,
+ boolean replaceStatistics) {
for (Path cleared : clearedPartitionPaths) {
Map<String, String> spec = clearedPartitionSpec(cleared);
if (spec != null) {
// Emptied and not written to: an exact zero, dated to the
commit that did it.
- statisticsByPartition.putIfAbsent(
- spec,
- new PartitionStatistics(
- spec,
- 0,
- 0,
- 0,
- commitTime,
- PartitionStatistics.UNKNOWN_TOTAL_BUCKETS));
+ statisticsByPartition.putIfAbsent(spec, emptyStatistics(spec,
commitTime));
}
}
@@ -263,13 +264,13 @@ public class FormatTableCommit implements
BatchTableCommit {
if (specs.isEmpty()) {
return;
}
- // An overwriting commit replaced what the partitions held, so what it
wrote is the total;
- // an appending one saw only its own files, so its numbers are an
increment.
+ // A commit that replaced what the partitions held reports a total; an
appending one saw
+ // only its own files, so its numbers are an increment.
partitionManager.createPartitions(
new ArrayList<>(specs),
true,
new ArrayList<>(statisticsByPartition.values()),
- overwrite);
+ replaceStatistics);
}
/** What one commit wrote into a partition, with one more of its files
folded in. */
@@ -388,6 +389,12 @@ public class FormatTableCommit implements BatchTableCommit
{
if (partitionSpec.isEmpty() || partitionKeys.isEmpty()) {
throw new IllegalArgumentException("partitionSpec or partitionKeys
is empty.");
}
+ if (partitionSpec.size() > partitionKeys.size()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Partition spec %s names more values than the
partition keys %s.",
+ partitionSpec, partitionKeys));
+ }
LinkedHashMap<String, String> orderedSpec = new LinkedHashMap<>();
for (int i = 0; i < partitionSpec.size(); i++) {
String key = partitionKeys.get(i);
@@ -442,16 +449,26 @@ public class FormatTableCommit implements
BatchTableCommit {
partitionLevels,
formatTablePartitionOnlyValueInPath,
defaultPartName)) {
+ boolean deleted;
try {
- // Only what this commit removed: a file another writer
deleted first would
- // have every concurrent writer report the whole subtree.
- if (fileIO.delete(file.getPath(), false)) {
- clearedPartitionPaths.add(file.getPath().getParent());
- }
+ deleted = fileIO.delete(file.getPath(), false);
} catch (FileNotFoundException ignore) {
+ continue;
} catch (IOException e) {
throw new RuntimeException(e);
}
+ if (deleted) {
+ // Only what this commit removed: a file another writer
deleted first would
+ // have every concurrent writer report the whole subtree.
+ clearedPartitionPaths.add(file.getPath().getParent());
+ } else if (fileIO.exists(file.getPath())) {
+ // A refusal is not that race: the file is still readable,
and going on would
+ // report the partition as holding nothing while its rows
are still there.
+ throw new IOException(
+ String.format(
+ "Failed to delete data file %s of table
%s.",
+ file.getPath(),
tableIdentifier.getFullName()));
+ }
}
}
return clearedPartitionPaths;
@@ -459,12 +476,155 @@ public class FormatTableCommit implements
BatchTableCommit {
@Override
public void truncateTable() {
- throw new UnsupportedOperationException();
+ // Data files only. The partition directories stay, and so do their
catalog registrations:
+ // emptying a table does not redefine which partitions it has.
+ if (partitionKeys == null || partitionKeys.isEmpty()) {
+ try {
+ deletePreviousDataFile(new Path(location), 0);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Failed to truncate table %s.",
tableIdentifier.getFullName()),
+ e);
+ }
+ return;
+ }
+ // Emptying the table is emptying every partition it has, and which
those are is answered
+ // by whatever the table reads its partitions from.
+ if (partitionManager != null) {
+ truncate(registeredPartitions(Collections.emptyMap()));
+ return;
+ }
+ // Filesystem partition discovery: the partition directories the scan
reads are the table.
+ // A directory that does not parse into the partition keys is not one
of them, so
+ // truncating leaves it alone.
+ for (Pair<LinkedHashMap<String, String>, Path> partition :
+ PartitionPathUtils.searchPartSpecAndPaths(
+ fileIO,
+ new Path(location),
+ partitionKeys.size(),
+ partitionKeys,
+ formatTablePartitionOnlyValueInPath,
+ null,
+ null,
+ defaultPartName)) {
+ try {
+ deletePreviousDataFile(partition.getRight(), 0);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Failed to truncate partition %s of table %s.",
+ partition.getLeft(),
tableIdentifier.getFullName()),
+ e);
+ }
+ }
}
@Override
public void truncatePartitions(List<Map<String, String>> partitionSpecs) {
- throw new UnsupportedOperationException();
+ if (partitionManager == null) {
+ truncate(partitionSpecs);
+ return;
+ }
+ // Complete specs are asked for in one request; only a prefix has to
be listed on its own.
+ List<Map<String, String>> complete = new ArrayList<>();
+ for (Map<String, String> partitionSpec : partitionSpecs) {
+ if (partitionSpec.size() == partitionKeys.size()) {
+ complete.add(partitionSpec);
+ }
+ }
+ Set<Map<String, String>> registered =
+ complete.isEmpty()
+ ? Collections.emptySet()
+ :
partitionManager.listPartitionsByNames(complete).stream()
+ .map(Partition::spec)
+ .collect(Collectors.toSet());
+ List<Map<String, String>> partitions = new ArrayList<>();
+ for (Map<String, String> partitionSpec : partitionSpecs) {
+ if (partitionSpec.size() == partitionKeys.size()) {
+ if (registered.contains(partitionSpec)) {
+ partitions.add(partitionSpec);
+ }
+ } else {
+ partitions.addAll(registeredPartitions(partitionSpec));
+ }
+ }
+ truncate(partitions);
+ }
+
+ /**
+ * The registered partitions named by {@code prefix}, which names only the
leading partition
+ * keys, or none of them. The catalog says which partitions a
catalog-managed table has, so
+ * truncating neither empties nor registers a directory still waiting for
MSCK REPAIR TABLE.
+ */
+ private List<Map<String, String>> registeredPartitions(Map<String, String>
prefix) {
+ return partitionManager.listPartitions(prefix, null).stream()
+ .map(Partition::spec)
+ .collect(Collectors.toList());
+ }
+
+ private void truncate(List<Map<String, String>> partitionSpecs) {
+ long truncateTime = System.currentTimeMillis();
+ Set<Path> clearedPartitionPaths = new HashSet<>();
+ // Statistics are keyed by the spec that named the partition, so only
a complete one can
+ // seed them; a prefix reaches here only for a table with nowhere to
report to.
+ Map<Map<String, String>, PartitionStatistics> emptied = new
LinkedHashMap<>();
+ RuntimeException failure = null;
+ for (Map<String, String> partitionSpec : partitionSpecs) {
+ Path partitionPath =
+ buildPartitionPath(
+ location,
+ partitionSpec,
+ formatTablePartitionOnlyValueInPath,
+ partitionKeys);
+ try {
+ clearedPartitionPaths.addAll(
+ deletePreviousDataFile(
+ partitionPath, partitionKeys.size() -
partitionSpec.size()));
+ } catch (Exception e) {
+ failure =
+ new RuntimeException(
+ String.format(
+ "Failed to truncate partition %s of
table %s.",
+ partitionSpec,
tableIdentifier.getFullName()),
+ e);
+ break;
+ }
+ if (partitionSpec.size() == partitionKeys.size()) {
+ emptied.put(partitionSpec, emptyStatistics(partitionSpec,
truncateTime));
+ }
+ }
+ if (partitionManager != null) {
+ // Truncating states that the partition holds nothing, whoever
deleted the files, so
+ // one that was already empty reports zero as well. An overwrite
reports only what it
+ // removed itself, so that concurrent writers do not each claim
the whole subtree;
+ // truncation makes the claim on purpose. What a failed truncation
emptied is reported
+ // too, so the catalog stops describing files that are gone.
+ try {
+ reportPartitions(
+ Collections.emptySet(),
+ emptied,
+ clearedPartitionPaths,
+ truncateTime,
+ /* replaceStatistics */ true);
+ } catch (RuntimeException e) {
+ if (failure == null) {
+ throw e;
+ }
+ // The deletion that failed first is the one that explains
what went wrong.
+ failure.addSuppressed(e);
+ }
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
+ /** What a partition holds once it has been emptied, dated to the commit
that emptied it. */
+ private static PartitionStatistics emptyStatistics(
+ Map<String, String> partitionSpec, long emptiedTime) {
+ return new PartitionStatistics(
+ partitionSpec, 0, 0, 0, emptiedTime,
PartitionStatistics.UNKNOWN_TOTAL_BUCKETS);
}
@Override
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
index 2cfc181ead..52e11e3c84 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java
@@ -57,13 +57,15 @@ public interface BatchTableCommit extends TableCommit {
/**
* Truncate table, like normal {@link #commit}, files are not immediately
deleted, they are only
- * logically deleted and will be deleted after the snapshot expires.
+ * logically deleted and will be deleted after the snapshot expires. A
table that keeps no
+ * snapshots, such as a Format Table, deletes them right away.
*/
void truncateTable();
/**
* Truncate partitions, like normal {@link #commit}, files are not
immediately deleted, they are
- * only logically deleted and will be deleted after the snapshot expires.
+ * only logically deleted and will be deleted after the snapshot expires.
A table that keeps no
+ * snapshots, such as a Format Table, deletes them right away.
*/
void truncatePartitions(List<Map<String, String>> partitionSpecs);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
index ed26d70742..199b7b446b 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
@@ -67,6 +67,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
/** Tests for the partition statistics a {@link FormatTableCommit} reports. */
class FormatTableCommitStatisticsTest {
@@ -222,6 +223,225 @@ class FormatTableCommitStatisticsTest {
});
}
+ @Test
+ void testTruncatingPartitionsReportsAnExactZeroAsTheTotal() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"), spec("2025", "11"));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ writeDataFile(fileIO, tablePath, "year=2025/month=11", "data.csv",
2048);
+
+ long before = System.currentTimeMillis();
+ commit(tablePath, fileIO, partitionManager, false, null)
+ .truncatePartitions(Arrays.asList(spec("2025", "10"),
spec("2025", "11")));
+ long after = System.currentTimeMillis();
+
+ Reported reported = capture(partitionManager);
+ // What a truncated partition holds is zero, not zero fewer rows than
before.
+ assertThat(reported.replaceStatistics).isTrue();
+ assertThat(reported.specs)
+ .containsExactlyInAnyOrder(spec("2025", "10"), spec("2025",
"11"));
+ // Red line: emptying a partition zeroes its statistics, it never
unregisters it.
+ verify(partitionManager, never()).dropPartitions(anyList());
+ // One catalog request for the complete specs, not one per partition.
+ verify(partitionManager).listPartitionsByNames(anyList());
+ verify(partitionManager, never()).listPartitions(any(), any());
+ // Statistics route by the spec they carry, so every partition needs
its own.
+ assertThat(reported.statistics)
+ .extracting(PartitionStatistics::spec)
+ .containsExactlyInAnyOrder(spec("2025", "10"), spec("2025",
"11"));
+ assertThat(reported.statistics)
+ .allSatisfy(
+ statistics -> {
+ assertThat(statistics.recordCount()).isZero();
+ assertThat(statistics.fileSizeInBytes()).isZero();
+ assertThat(statistics.fileCount()).isZero();
+ // Dated to the truncation. Reporting the time as
unknown would leave
+ // the stored one describing files that are gone,
since an unknown
+ // replaces nothing.
+
assertThat(statistics.lastFileCreationTime()).isBetween(before, after);
+ });
+ }
+
+ @Test
+ void testTruncatingAPartitionThatIsAlreadyEmptyStillReportsZero() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ // Registered, and its directory holds nothing to delete - whoever
emptied it, its stored
+ // statistics still describe the files that used to be there.
+ registered(partitionManager, spec("2025", "10"));
+ fileIO.mkdirs(new Path(tablePath, "year=2025/month=10"));
+
+ commit(tablePath, fileIO, partitionManager, false, null)
+ .truncatePartitions(Collections.singletonList(spec("2025",
"10")));
+
+ Reported reported = capture(partitionManager);
+ // Unlike an overwrite, which reports only the files it removed
itself, truncation states
+ // that the partition holds nothing.
+ assertThat(reported.replaceStatistics).isTrue();
+ assertThat(reported.specs).containsExactly(spec("2025", "10"));
+ assertThat(reported.statistics).hasSize(1);
+ assertThat(reported.statistics.get(0).spec()).isEqualTo(spec("2025",
"10"));
+ assertThat(reported.statistics.get(0).recordCount()).isZero();
+ assertThat(reported.statistics.get(0).fileCount()).isZero();
+ }
+
+ @Test
+ void
testTruncatingTheTableReportsARegisteredPartitionWhoseDirectoryIsGone() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"), spec("2025", "11"));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ // Registered but its directory is not there at all: someone deleted
it behind the catalog.
+ // A missing directory is drift to report on, not a reason to fail the
truncation of the
+ // partitions that do exist.
+
+ commit(tablePath, fileIO, partitionManager, false,
null).truncateTable();
+
+ Reported reported = capture(partitionManager);
+ assertThat(reported.replaceStatistics).isTrue();
+ assertThat(reported.specs)
+ .containsExactlyInAnyOrder(spec("2025", "10"), spec("2025",
"11"));
+ assertThat(reported.statistics)
+ .allSatisfy(
+ statistics -> {
+ assertThat(statistics.recordCount()).isZero();
+ assertThat(statistics.fileCount()).isZero();
+ });
+ }
+
+ @Test
+ void testTruncatingAPrefixReportsThePartitionsUnderneathIt() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ Map<String, String> prefix = Collections.singletonMap("year", "2025");
+ when(partitionManager.listPartitions(prefix, null))
+ .thenReturn(
+ Arrays.asList(
+ partition(spec("2025", "10")),
partition(spec("2025", "11"))));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ writeDataFile(fileIO, tablePath, "year=2025/month=11", "data.csv",
2048);
+ writeDataFile(fileIO, tablePath, "year=2024/month=12", "data.csv",
1024);
+ // Under the prefix but not registered: a directory still waiting for
MSCK REPAIR TABLE is
+ // not a partition of the table, so truncating neither empties nor
registers it.
+ writeDataFile(fileIO, tablePath, "year=2025/month=12", "data.csv",
512);
+
+ commit(tablePath, fileIO, partitionManager, false, null)
+ .truncatePartitions(Collections.singletonList(prefix));
+
+ Reported reported = capture(partitionManager);
+ // The prefix names no partition of its own; the partitions it empties
are the registered
+ // ones underneath it.
+ assertThat(reported.specs)
+ .containsExactlyInAnyOrder(spec("2025", "10"), spec("2025",
"11"));
+ assertThat(reported.replaceStatistics).isTrue();
+ assertThat(reported.statistics)
+ .allSatisfy(statistics ->
assertThat(statistics.recordCount()).isZero());
+ assertThat(fileIO.exists(new Path(tablePath,
"year=2025/month=12/data.csv"))).isTrue();
+ }
+
+ @Test
+ void testTruncatingTheTableReportsEveryRegisteredPartition() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"), spec("2025", "11"));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ writeDataFile(fileIO, tablePath, "year=2025/month=11", "data.csv",
2048);
+ // Not registered, so not part of the table and not reported on.
+ writeDataFile(fileIO, tablePath, "year=2025/month=12", "data.csv",
512);
+
+ commit(tablePath, fileIO, partitionManager, false,
null).truncateTable();
+
+ Reported reported = capture(partitionManager);
+ assertThat(reported.replaceStatistics).isTrue();
+ assertThat(reported.specs)
+ .containsExactlyInAnyOrder(spec("2025", "10"), spec("2025",
"11"));
+ assertThat(reported.statistics)
+ .allSatisfy(statistics ->
assertThat(statistics.fileCount()).isZero());
+ assertThat(fileIO.exists(new Path(tablePath,
"year=2025/month=12/data.csv"))).isTrue();
+ }
+
+ @Test
+ void testAFailedTruncationReportsThePartitionsItAlreadyEmptied() throws
Exception {
+ Path tablePath = new Path(tempDir.toUri());
+ LocalFileIO fileIO = new UndeletableFileIO(new Path(tablePath,
"year=2025/month=11"));
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"), spec("2025", "11"));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ writeDataFile(fileIO, tablePath, "year=2025/month=11", "data.csv",
2048);
+
+ assertThatThrownBy(
+ () ->
+ commit(tablePath, fileIO, partitionManager,
false, null)
+ .truncateTable())
+ .hasMessageContaining("month=11")
+ .hasMessageContaining(TABLE.getFullName());
+
+ // A Format Table has no snapshot to make the whole truncation atomic,
so what it emptied
+ // before the failure is reported anyway: the catalog must not keep
describing files that
+ // are gone.
+ Reported reported = capture(partitionManager);
+ assertThat(reported.specs).containsExactly(spec("2025", "10"));
+ assertThat(reported.statistics.get(0).fileCount()).isZero();
+ }
+
+ @Test
+ void testATruncationFailsWhenADeletionIsRefused() throws Exception {
+ Path tablePath = new Path(tempDir.toUri());
+ LocalFileIO fileIO = new RefusingFileIO();
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"));
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+
+ assertThatThrownBy(
+ () ->
+ commit(tablePath, fileIO, partitionManager,
false, null)
+ .truncateTable())
+ .hasMessageContaining("month=10")
+ .hasMessageContaining(TABLE.getFullName())
+ .hasStackTraceContaining("data.csv");
+
+ // A refused deletion is not a concurrent one: the rows are still
readable, so reporting
+ // the partition as holding nothing would hide them.
+ verify(partitionManager, never())
+ .createPartitions(anyList(), anyBoolean(), anyList(),
anyBoolean());
+ assertThat(fileIO.exists(new Path(tablePath,
"year=2025/month=10/data.csv"))).isTrue();
+ }
+
+ @Test
+ void testAFailedReportDoesNotHideTheDeletionThatFailedFirst() throws
Exception {
+ Path tablePath = new Path(tempDir.toUri());
+ LocalFileIO fileIO = new UndeletableFileIO(new Path(tablePath,
"year=2025/month=11"));
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ registered(partitionManager, spec("2025", "10"), spec("2025", "11"));
+ doThrow(new RuntimeException("the catalog is unreachable"))
+ .when(partitionManager)
+ .createPartitions(anyList(), anyBoolean(), anyList(),
anyBoolean());
+ writeDataFile(fileIO, tablePath, "year=2025/month=10", "data.csv",
4096);
+ writeDataFile(fileIO, tablePath, "year=2025/month=11", "data.csv",
2048);
+
+ assertThatThrownBy(
+ () ->
+ commit(tablePath, fileIO, partitionManager,
false, null)
+ .truncateTable())
+ // The deletion that failed first is what explains the
failure; the report that
+ // could not record the rest is attached to it.
+ .hasMessageContaining("month=11")
+ .satisfies(
+ thrown ->
+ assertThat(thrown.getSuppressed())
+ .anySatisfy(
+ suppressed ->
+ assertThat(suppressed)
+
.hasMessageContaining(
+
"unreachable")));
+ }
+
@Test
void
testAClearedPartitionIsFoundEvenWhenTheListingAnswersUnderAnotherScheme()
throws Exception {
@@ -416,6 +636,36 @@ class FormatTableCommitStatisticsTest {
}
}
+ /** A partition the catalog holds, whose statistics the truncation is
meant to replace. */
+ private static Partition partition(Map<String, String> spec) {
+ return new Partition(spec, 3, 4096, 1, 0,
PartitionStatistics.UNKNOWN_TOTAL_BUCKETS, false);
+ }
+
+ /** Which partitions the catalog says the table has. */
+ @SafeVarargs
+ private static void registered(
+ FormatTablePartitionManager partitionManager, Map<String,
String>... specs) {
+ List<Partition> partitions = new ArrayList<>();
+ for (Map<String, String> spec : specs) {
+ partitions.add(partition(spec));
+ when(partitionManager.listPartitions(spec, null))
+ .thenReturn(Collections.singletonList(partition(spec)));
+ }
+ when(partitionManager.listPartitions(Collections.emptyMap(),
null)).thenReturn(partitions);
+ when(partitionManager.listPartitionsByNames(anyList()))
+ .thenAnswer(
+ invocation -> {
+ List<Map<String, String>> asked =
invocation.getArgument(0);
+ List<Partition> found = new ArrayList<>();
+ for (Partition partition : partitions) {
+ if (asked.contains(partition.spec())) {
+ found.add(partition);
+ }
+ }
+ return found;
+ });
+ }
+
private static Map<String, String> spec(String year, String month) {
LinkedHashMap<String, String> spec = new LinkedHashMap<>();
spec.put("year", year);
@@ -446,6 +696,37 @@ class FormatTableCommitStatisticsTest {
* A {@link FileIO} that answers a listing with paths stripped of their
scheme, the way a
* delegating one does when it resolves the caller's scheme to the one it
really uses.
*/
+ /** A file IO whose deletions all report failure, leaving the files in
place. */
+ private static class RefusingFileIO extends LocalFileIO {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public boolean delete(Path path, boolean recursive) {
+ return false;
+ }
+ }
+
+ /** A file IO that refuses to delete anything under one directory. */
+ private static class UndeletableFileIO extends LocalFileIO {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String directory;
+
+ private UndeletableFileIO(Path directory) {
+ this.directory = directory.toString();
+ }
+
+ @Override
+ public boolean delete(Path path, boolean recursive) throws IOException
{
+ if (path.toString().startsWith(directory)) {
+ throw new IOException("Refused to delete " + path);
+ }
+ return super.delete(path, recursive);
+ }
+ }
+
private static class RescopingFileIO extends LocalFileIO {
private static final long serialVersionUID = 1L;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
index 6d3d6da6f2..db12129814 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
@@ -23,6 +23,7 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.RenamingTwoPhaseOutputStream;
import org.apache.paimon.fs.TwoPhaseOutputStream;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.partition.Partition;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.utils.PartitionPathUtils;
@@ -49,6 +50,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
/** Tests for {@link FormatTableCommit}. */
class FormatTableCommitTest {
@@ -359,6 +361,215 @@ class FormatTableCommitTest {
assertThat(fileIO.exists(siblingPath)).isTrue();
}
+ @Test
+ void
testTruncateTableEmptiesEveryPartitionAndLeavesThePartitionsThemselves() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path october = new Path(tablePath, "year=2025/month=10");
+ Path november = new Path(tablePath, "year=2025/month=11");
+ Path octoberData = new Path(october, "data-1.csv");
+ Path novemberData = new Path(november, "data-2.csv");
+ fileIO.writeFile(octoberData, "1", false);
+ fileIO.writeFile(novemberData, "2", false);
+ // Another writer is mid-write in this partition; its staging tree is
not table data.
+ Path stagingFile = new Path(october,
"_temporary/attempt/part-00000.csv");
+ fileIO.writeFile(stagingFile, "3", false);
+ FormatTableCommit commit =
+ truncatingCommit(tablePath, fileIO, false, null, "year",
"month");
+
+ commit.truncateTable();
+
+ assertThat(fileIO.exists(octoberData)).isFalse();
+ assertThat(fileIO.exists(novemberData)).isFalse();
+ assertThat(fileIO.exists(stagingFile)).isTrue();
+ // Emptying a table does not redefine which partitions it has: the
directories stay.
+ assertThat(fileIO.exists(october)).isTrue();
+ assertThat(fileIO.exists(november)).isTrue();
+ }
+
+ @Test
+ void
testTruncateTableEmptiesTheRegisteredPartitionsOfACatalogManagedTable() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path registered = new Path(tablePath, "year=2025/month=10");
+ Path registeredData = new Path(registered, "data-1.csv");
+ fileIO.writeFile(registeredData, "1", false);
+ // Dropped there by something outside Paimon and not registered yet,
so it is not part of
+ // the table: MSCK REPAIR TABLE is what would make it so.
+ Path awaitingRepair = new Path(tablePath, "year=2025/month=11");
+ Path awaitingRepairData = new Path(awaitingRepair, "data-2.csv");
+ fileIO.writeFile(awaitingRepairData, "2", false);
+ FormatTablePartitionManager partitionManager =
mock(FormatTablePartitionManager.class);
+ when(partitionManager.listPartitions(Collections.emptyMap(), null))
+ .thenReturn(
+ Collections.singletonList(
+ new Partition(partitionSpec("2025", "10"), 0,
0, 0, 0, -1, false)));
+ FormatTableCommit commit =
+ truncatingCommit(tablePath, fileIO, false, partitionManager,
"year", "month");
+
+ commit.truncateTable();
+
+ assertThat(fileIO.exists(registeredData)).isFalse();
+ assertThat(fileIO.exists(registered)).isTrue();
+ assertThat(fileIO.exists(awaitingRepairData)).isTrue();
+ }
+
+ @Test
+ void testTruncateTableOnlyEmptiesTheDirectoriesThatAreItsPartitions()
throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path partitionData = new Path(tablePath,
"year=2025/month=10/data-1.csv");
+ fileIO.writeFile(partitionData, "1", false);
+ // Neither of these is a partition: the scan reads the directories
that parse into the
+ // partition keys, so nothing else under the table directory is table
data.
+ Path atTheTableRoot = new Path(tablePath, "notes.csv");
+ fileIO.writeFile(atTheTableRoot, "2", false);
+ Path outsideThePartitionLayout = new Path(tablePath,
"tmp/unknown/x.csv");
+ fileIO.writeFile(outsideThePartitionLayout, "3", false);
+ FormatTableCommit commit =
+ truncatingCommit(tablePath, fileIO, false, null, "year",
"month");
+
+ commit.truncateTable();
+
+ assertThat(fileIO.exists(partitionData)).isFalse();
+ assertThat(fileIO.exists(atTheTableRoot)).isTrue();
+ assertThat(fileIO.exists(outsideThePartitionLayout)).isTrue();
+ }
+
+ @Test
+ void
testTruncateTableClearsAValueOnlyDefaultPartitionBelowTheTableDirectory()
+ throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ // Value-only layout: a null month is the default partition name,
which starts with '_'
+ // without being a staging directory, and here it sits below the
listed directory instead
+ // of being it.
+ Path defaultPartition =
+ new Path(tablePath, "2025/" +
PARTITION_DEFAULT_NAME.defaultValue());
+ Path staleFile = new Path(defaultPartition, "data-old.csv");
+ fileIO.writeFile(staleFile, "1", false);
+ Path stagingFile = new Path(tablePath,
"2025/_temporary/attempt/part-00000.csv");
+ fileIO.writeFile(stagingFile, "2", false);
+ FormatTableCommit commit = truncatingCommit(tablePath, fileIO, true,
null, "year", "month");
+
+ commit.truncateTable();
+
+ assertThat(fileIO.exists(staleFile)).isFalse();
+ assertThat(fileIO.exists(stagingFile)).isTrue();
+ assertThat(fileIO.exists(defaultPartition)).isTrue();
+ }
+
+ @Test
+ void testTruncateTableOfAnUnpartitionedTableClearsTheTableDirectory()
throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path dataFile = new Path(tablePath, "data-1.csv");
+ fileIO.writeFile(dataFile, "1", false);
+ Path stagingFile = new Path(tablePath,
"_temporary/attempt/part-00000.csv");
+ fileIO.writeFile(stagingFile, "2", false);
+ FormatTableCommit commit = truncatingCommit(tablePath, fileIO, false,
null);
+
+ commit.truncateTable();
+
+ assertThat(fileIO.exists(dataFile)).isFalse();
+ assertThat(fileIO.exists(stagingFile)).isTrue();
+ assertThat(fileIO.exists(tablePath)).isTrue();
+ }
+
+ @Test
+ void testTruncatePartitionsStaysInsideThePartitionsItNames() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path october = new Path(tablePath, "year=2025/month=10");
+ Path november = new Path(tablePath, "year=2025/month=11");
+ Path octoberData = new Path(october, "data-1.csv");
+ Path novemberData = new Path(november, "data-2.csv");
+ fileIO.writeFile(octoberData, "1", false);
+ fileIO.writeFile(novemberData, "2", false);
+ Map<String, String> october2025 = new LinkedHashMap<>();
+ october2025.put("year", "2025");
+ october2025.put("month", "10");
+ FormatTableCommit commit =
+ truncatingCommit(tablePath, fileIO, false, null, "year",
"month");
+
+ commit.truncatePartitions(Collections.singletonList(october2025));
+
+ assertThat(fileIO.exists(octoberData)).isFalse();
+ assertThat(fileIO.exists(october)).isTrue();
+ assertThat(fileIO.exists(novemberData)).isTrue();
+ }
+
+ @Test
+ void testTruncatingAPrefixClearsThePartitionsBelowItButNotStagingTrees()
throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ Path staleFile = new Path(tablePath,
"year=2025/month=10/data-old.csv");
+ fileIO.writeFile(staleFile, "1", false);
+ // A job writing this prefix with the month left dynamic stages
exactly where the month
+ // directories sit, so a directory at a partition level is not
automatically partition data.
+ Path stagingFile =
+ new Path(tablePath,
"year=2025/_temporary/attempt/month=11/part-00011.csv");
+ fileIO.writeFile(stagingFile, "2", false);
+ Path otherYear = new Path(tablePath,
"year=2024/month=10/data-old.csv");
+ fileIO.writeFile(otherYear, "3", false);
+ FormatTableCommit commit =
+ truncatingCommit(tablePath, fileIO, false, null, "year",
"month");
+
+ commit.truncatePartitions(
+ Collections.singletonList(Collections.singletonMap("year",
"2025")));
+
+ assertThat(fileIO.exists(staleFile)).isFalse();
+ assertThat(fileIO.exists(stagingFile)).isTrue();
+ assertThat(fileIO.exists(otherYear)).isTrue();
+ }
+
+ @Test
+ void testTruncatingTheValueOnlyDefaultPartitionClearsIt() throws Exception
{
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ // Value-only layout: a null partition value is the default partition
name, which starts
+ // with '_' without being a staging directory. The scan reads it, so
TRUNCATE clears it.
+ Path defaultPartition = new Path(tablePath,
PARTITION_DEFAULT_NAME.defaultValue());
+ Path staleFile = new Path(defaultPartition, "data-old.csv");
+ fileIO.writeFile(staleFile, "1", false);
+ FormatTableCommit commit = truncatingCommit(tablePath, fileIO, true,
null, "year");
+
+ commit.truncatePartitions(
+ Collections.singletonList(
+ Collections.singletonMap("year",
PARTITION_DEFAULT_NAME.defaultValue())));
+
+ assertThat(fileIO.exists(staleFile)).isFalse();
+ assertThat(fileIO.exists(defaultPartition)).isTrue();
+ }
+
+ private static Map<String, String> partitionSpec(String year, String
month) {
+ LinkedHashMap<String, String> spec = new LinkedHashMap<>();
+ spec.put("year", year);
+ spec.put("month", month);
+ return spec;
+ }
+
+ /** The commit TRUNCATE makes: nothing to write, so no overwrite and no
static partition. */
+ private FormatTableCommit truncatingCommit(
+ Path tableLocation,
+ LocalFileIO fileIO,
+ boolean onlyValueInPath,
+ FormatTablePartitionManager partitionManager,
+ String... partitionKeys) {
+ return new FormatTableCommit(
+ tableLocation.toString(),
+ Arrays.asList(partitionKeys),
+ fileIO,
+ onlyValueInPath,
+ PARTITION_DEFAULT_NAME.defaultValue(),
+ false,
+ Identifier.create("truncate_db", "truncate_table"),
+ null,
+ null,
+ null,
+ partitionManager);
+ }
+
private FormatTablePartitionManager commitPartitionedFile(
Path tableLocation, boolean onlyValueInPath, String partitionDir)
throws Exception {
LocalFileIO fileIO = LocalFileIO.create();
diff --git
a/paimon-flink/paimon-flink-1.16/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
b/paimon-flink/paimon-flink-1.16/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
new file mode 100644
index 0000000000..f8f6f62cae
--- /dev/null
+++
b/paimon-flink/paimon-flink-1.16/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.table.FormatTable;
+
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.factories.DynamicTableFactory;
+
+/** Table sink for format tables. Flink 1.16 has no SupportsTruncate. */
+public class FlinkFormatTableSink extends FlinkFormatTableSinkBase {
+
+ public FlinkFormatTableSink(
+ ObjectIdentifier tableIdentifier,
+ FormatTable table,
+ DynamicTableFactory.Context context) {
+ super(tableIdentifier, table, context);
+ }
+}
diff --git
a/paimon-flink/paimon-flink-1.17/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
b/paimon-flink/paimon-flink-1.17/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
new file mode 100644
index 0000000000..b10e4cd91e
--- /dev/null
+++
b/paimon-flink/paimon-flink-1.17/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.table.FormatTable;
+
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.factories.DynamicTableFactory;
+
+/** Table sink for format tables. Flink 1.17 has no SupportsTruncate. */
+public class FlinkFormatTableSink extends FlinkFormatTableSinkBase {
+
+ public FlinkFormatTableSink(
+ ObjectIdentifier tableIdentifier,
+ FormatTable table,
+ DynamicTableFactory.Context context) {
+ super(tableIdentifier, table, context);
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
index 1c48602098..c31b723abf 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
@@ -18,80 +18,37 @@
package org.apache.paimon.flink.sink;
-import org.apache.paimon.flink.PaimonDataStreamSinkProvider;
import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
import org.apache.flink.table.catalog.ObjectIdentifier;
-import org.apache.flink.table.connector.ChangelogMode;
-import org.apache.flink.table.connector.sink.DynamicTableSink;
-import org.apache.flink.table.connector.sink.abilities.SupportsOverwrite;
-import org.apache.flink.table.connector.sink.abilities.SupportsPartitioning;
+import org.apache.flink.table.connector.sink.abilities.SupportsTruncate;
import org.apache.flink.table.factories.DynamicTableFactory;
-import java.util.HashMap;
-import java.util.Map;
-
/** Table sink for format tables. */
-public class FlinkFormatTableSink
- implements DynamicTableSink, SupportsOverwrite, SupportsPartitioning {
-
- private final ObjectIdentifier tableIdentifier;
- private final FormatTable table;
- private final DynamicTableFactory.Context context;
- private Map<String, String> staticPartitions = new HashMap<>();
- protected boolean overwrite = false;
+public class FlinkFormatTableSink extends FlinkFormatTableSinkBase implements
SupportsTruncate {
public FlinkFormatTableSink(
ObjectIdentifier tableIdentifier,
FormatTable table,
DynamicTableFactory.Context context) {
- this.tableIdentifier = tableIdentifier;
- this.table = table;
- this.context = context;
- }
-
- @Override
- public ChangelogMode getChangelogMode(ChangelogMode requestedMode) {
- throw new UnsupportedOperationException("Format Table doesn't support
changelog mode.");
- }
-
- @Override
- public SinkRuntimeProvider getSinkRuntimeProvider(Context context) {
- return new PaimonDataStreamSinkProvider(
- (dataStream) ->
- new FlinkFormatTableDataStreamSink(table, overwrite,
staticPartitions)
- .sinkFrom(dataStream),
- tableIdentifier.asSummaryString(),
- table);
- }
-
- @Override
- public DynamicTableSink copy() {
- FlinkFormatTableSink copied = new
FlinkFormatTableSink(tableIdentifier, table, context);
- copied.staticPartitions = new HashMap<>(staticPartitions);
- copied.overwrite = overwrite;
- return copied;
- }
-
- @Override
- public String asSummaryString() {
- return "PaimonFormatTableSink";
- }
-
- @Override
- public void applyStaticPartition(Map<String, String> partition) {
- table.partitionKeys()
- .forEach(
- partitionKey -> {
- if (partition.containsKey(partitionKey)) {
- this.staticPartitions.put(
- partitionKey,
partition.get(partitionKey));
- }
- });
+ super(tableIdentifier, table, context);
}
+ /**
+ * Removes the data of the whole table - of its registered partitions,
when the catalog manages
+ * them. The partition directories stay, and so do their catalog
registrations: emptying a table
+ * does not redefine which partitions it has.
+ */
@Override
- public void applyOverwrite(boolean overwrite) {
- this.overwrite = overwrite;
+ public void executeTruncation() {
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.truncateTable();
+ } catch (Exception e) {
+ throw new RuntimeException(
+ String.format(
+ "Failed to truncate table %s.",
tableIdentifier.asSummaryString()),
+ e);
+ }
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSinkBase.java
similarity index 91%
copy from
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
copy to
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSinkBase.java
index 1c48602098..7add7f64c1 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSink.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkFormatTableSinkBase.java
@@ -32,16 +32,16 @@ import java.util.HashMap;
import java.util.Map;
/** Table sink for format tables. */
-public class FlinkFormatTableSink
+public abstract class FlinkFormatTableSinkBase
implements DynamicTableSink, SupportsOverwrite, SupportsPartitioning {
- private final ObjectIdentifier tableIdentifier;
- private final FormatTable table;
- private final DynamicTableFactory.Context context;
- private Map<String, String> staticPartitions = new HashMap<>();
+ protected final ObjectIdentifier tableIdentifier;
+ protected final FormatTable table;
+ protected final DynamicTableFactory.Context context;
+ protected Map<String, String> staticPartitions = new HashMap<>();
protected boolean overwrite = false;
- public FlinkFormatTableSink(
+ public FlinkFormatTableSinkBase(
ObjectIdentifier tableIdentifier,
FormatTable table,
DynamicTableFactory.Context context) {
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
index 43f47e433e..63da32b3fe 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
@@ -21,11 +21,14 @@ package org.apache.paimon.flink.source;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.Decimal;
import org.apache.paimon.flink.RESTCatalogITCaseBase;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.rest.RESTToken;
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap;
import org.apache.flink.types.Row;
+import org.apache.flink.types.RowKind;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
@@ -42,19 +45,10 @@ public class FormatTableITCase extends
RESTCatalogITCaseBase {
Decimal decimal = Decimal.fromBigDecimal(new
BigDecimal(bigDecimalStr), 8, 3);
for (String format : new String[] {"parquet", "csv", "json"}) {
String tableName = "format_table_parquet_" + format.toLowerCase();
- Identifier identifier = Identifier.create("default", tableName);
sql(
"CREATE TABLE %s (a DECIMAL(8, 3), b INT, c INT) WITH
('file.format'='%s', 'type'='format-table')",
tableName, format);
- RESTToken expiredDataToken =
- new RESTToken(
- ImmutableMap.of(
- "akId",
- "akId-expire",
- "akSecret",
- UUID.randomUUID().toString()),
- System.currentTimeMillis() + 1000_000);
- restCatalogServer.setDataToken(identifier, expiredDataToken);
+ setDataToken(tableName);
sql("INSERT INTO %s VALUES (%s, 1, 1), (%s, 2, 2)", tableName,
decimal, decimal);
assertThat(sql("SELECT a, b FROM %s", tableName))
.containsExactlyInAnyOrder(
@@ -68,16 +62,10 @@ public class FormatTableITCase extends
RESTCatalogITCaseBase {
public void testPartitionedTableInsertOverwrite() {
String ptTableName = "format_table_overwrite";
- Identifier ptIdentifier = Identifier.create("default", ptTableName);
sql(
"CREATE TABLE %s (a DECIMAL(8, 3), b INT, c INT) PARTITIONED
BY (c) WITH ('file.format'='parquet', 'type'='format-table')",
ptTableName);
- RESTToken expiredDataToken =
- new RESTToken(
- ImmutableMap.of(
- "akId", "akId-expire", "akSecret",
UUID.randomUUID().toString()),
- System.currentTimeMillis() + 1000_000);
- restCatalogServer.setDataToken(ptIdentifier, expiredDataToken);
+ setDataToken(ptTableName);
String ptBigDecimalStr1 = "10.001";
String ptBigDecimalStr2 = "12.345";
@@ -125,16 +113,10 @@ public class FormatTableITCase extends
RESTCatalogITCaseBase {
Decimal decimal1 = Decimal.fromBigDecimal(new
BigDecimal(bigDecimalStr1), 8, 3);
Decimal decimal2 = Decimal.fromBigDecimal(new
BigDecimal(bigDecimalStr2), 8, 3);
- Identifier identifier = Identifier.create("default", tableName);
sql(
"CREATE TABLE %s (a DECIMAL(8, 3), b INT, c INT) WITH
('file.format'='parquet', 'type'='format-table')",
tableName);
- RESTToken expiredDataToken =
- new RESTToken(
- ImmutableMap.of(
- "akId", "akId-expire", "akSecret",
UUID.randomUUID().toString()),
- System.currentTimeMillis() + 1000_000);
- restCatalogServer.setDataToken(identifier, expiredDataToken);
+ setDataToken(tableName);
sql("INSERT INTO %s VALUES (%s, 1, 1), (%s, 2, 2)", tableName,
decimal1, decimal1);
assertThat(sql("SELECT a, b FROM %s", tableName))
@@ -150,4 +132,68 @@ public class FormatTableITCase extends
RESTCatalogITCaseBase {
sql("Drop TABLE %s", tableName);
}
+
+ @Test
+ public void testTruncateTable() {
+ String tableName = "format_table_truncate";
+ sql(
+ "CREATE TABLE %s (a INT, b INT) WITH ('file.format'='parquet',
'type'='format-table')",
+ tableName);
+ setDataToken(tableName);
+
+ sql("INSERT INTO %s VALUES (1, 11), (2, 22)", tableName);
+ assertThat(sql("SELECT * FROM %s", tableName))
+ .containsExactlyInAnyOrder(Row.of(1, 11), Row.of(2, 22));
+
+ assertThat(sql("TRUNCATE TABLE %s", tableName))
+ .containsExactly(Row.ofKind(RowKind.INSERT, "OK"));
+ assertThat(sql("SELECT * FROM %s", tableName)).isEmpty();
+
+ // The table is empty, not gone: it takes writes again.
+ sql("INSERT INTO %s VALUES (3, 33)", tableName);
+ assertThat(sql("SELECT * FROM %s",
tableName)).containsExactly(Row.of(3, 33));
+
+ sql("DROP TABLE %s", tableName);
+ }
+
+ @Test
+ public void testTruncatePartitionedTable() throws Exception {
+ String tableName = "format_table_truncate_partitioned";
+ sql(
+ "CREATE TABLE %s (a INT, b INT) PARTITIONED BY (b) WITH
('file.format'='parquet', 'type'='format-table')",
+ tableName);
+ setDataToken(tableName);
+
+ sql("INSERT INTO %s PARTITION (b = 1) VALUES (10)", tableName);
+ sql("INSERT INTO %s PARTITION (b = 2) VALUES (20)", tableName);
+ assertThat(sql("SELECT a, b FROM %s", tableName))
+ .containsExactlyInAnyOrder(Row.of(10, 1), Row.of(20, 2));
+
+ sql("TRUNCATE TABLE %s", tableName);
+ assertThat(sql("SELECT a, b FROM %s", tableName)).isEmpty();
+
+ // Emptied, not dropped: the partition directory of the one nothing
was written back to
+ // is still there, and all that is left in it is a writer's staging
tree.
+ Path emptied = new Path(dataPath, "default.db/" + tableName + "/b=2");
+ LocalFileIO fileIO = LocalFileIO.create();
+ assertThat(fileIO.exists(emptied)).isTrue();
+ assertThat(fileIO.listStatus(emptied))
+ .extracting(status -> status.getPath().getName())
+ .allMatch(name -> name.startsWith("_"));
+
+ // Every partition was emptied, and each of them takes writes again.
+ sql("INSERT INTO %s PARTITION (b = 1) VALUES (100)", tableName);
+ assertThat(sql("SELECT a, b FROM %s",
tableName)).containsExactly(Row.of(100, 1));
+
+ sql("DROP TABLE %s", tableName);
+ }
+
+ private void setDataToken(String tableName) {
+ restCatalogServer.setDataToken(
+ Identifier.create("default", tableName),
+ new RESTToken(
+ ImmutableMap.of(
+ "akId", "akId-expire", "akSecret",
UUID.randomUUID().toString()),
+ System.currentTimeMillis() + 1000_000));
+ }
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonPartitionManagement.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonPartitionManagement.scala
index 7bf9578989..392b127ff1 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonPartitionManagement.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonPartitionManagement.scala
@@ -58,10 +58,10 @@ trait PaimonPartitionManagement extends
SupportsAtomicPartitionManagement with L
toPaimonPartition(r, partitionKeys.take(r.numFields))
}
case _: FormatTable =>
- // Reached by the partition operations this trait still serves
directly, such as TRUNCATE
- // PARTITION. Saying that only a FileStoreTable has partitions would
be wrong for a Format
- // Table with catalog-managed partitions, which has them and lists
them here; a Format
- // Table is still a Paimon table, just not a native one.
+ // Reached by the partition operations this trait still serves
directly. Saying that only
+ // a FileStoreTable has partitions would be wrong for a Format Table
with catalog-managed
+ // partitions, which has them and lists them here; a Format Table is
still a Paimon table,
+ // just not a native one.
throw new UnsupportedOperationException(
s"This partition operation is supported only for a native Paimon
table; " +
s"${table.name()} is a Format Table, which manages its partitions
through " +
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
index 6b5e5210f9..131fc4e2b3 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
@@ -25,11 +25,13 @@ import org.apache.paimon.spark.{BaseTable,
FormatTableScanBuilder}
import org.apache.paimon.spark.write.{BaseV2WriteBuilder,
PaimonWriteRequirement}
import org.apache.paimon.table.FormatTable
import org.apache.paimon.table.format.FormatTablePartitionManager
+import org.apache.paimon.table.sink.BatchTableCommit
import org.apache.paimon.types.RowType
import org.apache.paimon.utils.PartitionPathUtils
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.connector.catalog.{SupportsRead, SupportsWrite,
TableCapability, TableCatalog}
+import org.apache.spark.sql.catalyst.analysis.{NoSuchPartitionException,
NoSuchPartitionsException}
+import org.apache.spark.sql.connector.catalog.{SupportsRead, SupportsWrite,
TableCapability, TableCatalog, TruncatableTable}
import org.apache.spark.sql.connector.catalog.TableCapability.{BATCH_READ,
BATCH_WRITE, OVERWRITE_BY_FILTER, OVERWRITE_DYNAMIC}
import org.apache.spark.sql.connector.distributions.Distribution
import org.apache.spark.sql.connector.expressions.SortOrder
@@ -49,7 +51,8 @@ import scala.collection.mutable.{ArrayBuffer, HashSet}
case class PaimonFormatTable(table: FormatTable)
extends BaseTable
with SupportsRead
- with SupportsWrite {
+ with SupportsWrite
+ with TruncatableTable {
// A Format Table uses catalog-managed partitions exactly when the catalog
gave it a partition
// manager; tables using filesystem partition discovery return null and rely
on the directory
@@ -92,6 +95,90 @@ case class PaimonFormatTable(table: FormatTable)
PaimonFormatTableWriterBuilder(table, info.schema)
}
+ /**
+ * Removes the data of the whole table - of its registered partitions, when
the catalog manages
+ * them. The partition directories stay, and so do their catalog
registrations: emptying a table
+ * does not redefine which partitions it has (SPARK-34418).
+ */
+ override def truncateTable(): Boolean = {
+ withCommit(_.truncateTable())
+ true
+ }
+
+ override def truncatePartitions(idents: Array[InternalRow]): Boolean = {
+ truncateFormatTablePartitions(
+ idents,
+ missing => new NoSuchPartitionsException(name(), missing.toSeq,
partitionSchema))
+ }
+
+ override def truncatePartition(ident: InternalRow): Boolean = {
+ truncateFormatTablePartitions(
+ Array(ident),
+ missing => new NoSuchPartitionException(name(), missing.head,
partitionSchema))
+ }
+
+ /**
+ * Removes the data of the given partitions, keeping the partitions
themselves (see
+ * [[truncateTable]]). Spark resolves a partial spec to the partitions it
covers before calling
+ * either entry point, so every spec arriving here is complete.
+ *
+ * A partition the table does not have cannot be truncated, and each entry
point reports that the
+ * way Spark expects it to. What the table has is answered by the catalog
for catalog-managed
+ * partitions and by the directory for filesystem partition discovery - each
kind is asked the
+ * same source it reads its partitions from, so data merely awaiting
registration is never emptied
+ * behind MSCK REPAIR TABLE's back.
+ *
+ * The partitions are truncated one after another. A Format Table has no
snapshot to make that
+ * atomic, so a failure part-way leaves the partitions handled before it
empty - as a failing
+ * `INSERT OVERWRITE` of several partitions does.
+ */
+ private def truncateFormatTablePartitions(
+ idents: Array[InternalRow],
+ noSuchPartitions: Array[InternalRow] => Throwable): Boolean = {
+ if (idents.isEmpty) {
+ return true
+ }
+ val partitionKeys = table.partitionKeys().asScala.toSeq
+ val specs = idents.map {
+ ident =>
+ require(
+ ident.numFields == partitionKeys.size,
+ s"Truncating a partition of Format Table ${table.fullName()} needs a
complete spec " +
+ s"for partition keys ${partitionKeys.mkString("[", ", ", "]")}, " +
+ s"but got ${ident.numFields} values."
+ )
+ toPaimonPartition(ident, partitionKeys)
+ }
+ val onlyValueInPath =
+
CoreOptions.fromMap(table.options()).formatTablePartitionOnlyValueInPath()
+ // Resolve (and path-safety validate) every directory before deleting
anything, as ADD and DROP
+ // PARTITION do.
+ val partitionPaths =
+ specs.map(spec => resolvePartitionPathWithinTable(orderedSpec(spec),
onlyValueInPath))
+ val exists = if (hasCatalogManagedPartitions) {
+ val partitionNames = partitionKeys.toArray
+ formatTablePartitionsRegistered(idents.map(_ => partitionNames), idents)
+ } else {
+ val fileIO = table.fileIO()
+ partitionPaths.map(fileIO.exists)
+ }
+ val missing = idents.zip(exists).collect { case (ident, false) => ident }
+ if (missing.nonEmpty) {
+ throw noSuchPartitions(missing)
+ }
+ withCommit(_.truncatePartitions(specs.toSeq.asJava))
+ true
+ }
+
+ private def withCommit(operation: BatchTableCommit => Unit): Unit = {
+ val commit = table.newBatchWriteBuilder().newCommit()
+ try {
+ operation(commit)
+ } finally {
+ commit.close()
+ }
+ }
+
/**
* Resolves, with a single catalog list-by-names lookup, which of the given
complete partition
* specs are registered. The result is aligned with the input arrays.
@@ -233,8 +320,9 @@ case class PaimonFormatTable(table: FormatTable)
/**
* Build the partition directory for a spec and verify it stays strictly
under the table location.
* Value-only path components are validated (including rejecting '.'/'..'),
and the normalized
- * path is checked against the table location so neither DROP (recursive
delete) nor ADD (mkdirs)
- * can escape the table directory via crafted or corrupt partition values.
+ * path is checked against the table location so no DROP (recursive delete),
ADD (mkdirs) or
+ * TRUNCATE (delete of the files below it) can escape the table directory
via crafted or corrupt
+ * partition values.
*/
private def resolvePartitionPathWithinTable(
orderedSpec: util.LinkedHashMap[String, String],
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
index 191fe2cb46..103aae3aea 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
@@ -45,6 +45,29 @@ class CatalogManagedPartitionTest extends
PaimonSparkTestWithRestCatalogBase {
}
}
+ test("TRUNCATE on a catalog-managed Format Table empties data and keeps the
registrations") {
+ val tableName = "catalog_partition_truncate"
+ withTable(tableName) {
+ sql(s"""CREATE TABLE $tableName (id INT, dt STRING)
+ |USING CSV
+ |PARTITIONED BY (dt)
+ |TBLPROPERTIES (
+ | 'format-table.implementation' = 'paimon',
+ | 'metastore.partitioned-table' = 'true')
+ |""".stripMargin)
+ sql(s"INSERT INTO $tableName VALUES (1, '20260715'), (2, '20260716')")
+
+ sql(s"TRUNCATE TABLE $tableName PARTITION (dt = '20260715')")
+ checkAnswer(sql(s"SELECT id FROM $tableName"), Seq(Row(2)))
+ checkAnswer(sql(s"SHOW PARTITIONS $tableName"), Seq(Row("dt=20260715"),
Row("dt=20260716")))
+
+ sql(s"TRUNCATE TABLE $tableName")
+ checkAnswer(sql(s"SELECT id FROM $tableName"), Seq.empty)
+ // The registrations outlive the data they pointed at: TRUNCATE is not
DROP PARTITION.
+ checkAnswer(sql(s"SHOW PARTITIONS $tableName"), Seq(Row("dt=20260715"),
Row("dt=20260716")))
+ }
+ }
+
test("catalog-managed Format Table sends partition predicate to REST
filtered listing") {
val tableName = "catalog_partition_filter_pushdown"
withTable(tableName) {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
index 8febee0e0a..0488594fcc 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
@@ -30,6 +30,7 @@ import org.apache.paimon.types.DataTypes
import org.apache.spark.SparkFunSuite
import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.analysis.{NoSuchPartitionException,
NoSuchPartitionsException}
import org.apache.spark.sql.catalyst.expressions.GenericInternalRow
import org.apache.spark.unsafe.types.UTF8String
@@ -233,6 +234,7 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
// partition stays registered; re-running ADD ... IF NOT EXISTS then
creates the directory.
assert(error eq failure)
assert(gateway.partitions == Seq(Map("dt" -> "20260715", "hh" -> "10")))
+ assert(gateway.listCalls == 0)
} finally {
delegate.delete(tablePath, true)
}
@@ -585,6 +587,7 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
// call already created: resolving duplicates is the catalog's job, not
Spark's.
assert(gateway.createRequests == Seq((Seq(first, second), true),
(Seq(second, third), true)))
assert(gateway.lookupCalls == 0)
+ assert(gateway.listCalls == 0)
}
test("catalog-managed strict ADD asks the catalog to reject duplicates,
without looking first") {
@@ -601,9 +604,10 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
}
assert(error.partition == existing)
- // Rejecting the batch is the catalog's decision; Spark must not pre-empt
it with a lookup,
- // which is what would break the batch's atomicity.
+ // Rejecting the batch is the catalog's decision; Spark must not pre-empt
it with a lookup
+ // or an enumeration, which is what would break the batch's atomicity.
assert(gateway.lookupCalls == 0)
+ assert(gateway.listCalls == 0)
}
test("catalog-managed ADD preserves typed special partition values") {
@@ -623,6 +627,100 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
assert(
gateway.createRequests == Seq(
(values.map(value => Map("dt" -> value)) :+ Map("dt" ->
"__DEFAULT_PARTITION__"), true)))
+ assert(gateway.listCalls == 0)
+ }
+
+ test("catalog-managed TRUNCATE PARTITION empties the partition without
unregistering it") {
+ val fileIO = LocalFileIO.create()
+ val tablePath = new
Path(Files.createTempDirectory("catalog-partition-format-truncate").toUri)
+ val registered = Seq(partitionSpec(20260715, 10), partitionSpec(20260716,
11))
+ val gateway = new InMemoryPartitionManager(registered)
+ val table = formatTableWithCatalogManagedPartitions(fileIO,
tablePath.toString, gateway)
+ try {
+ val truncated = new Path(tablePath, "dt=20260715/hh=10")
+ val kept = new Path(tablePath, "dt=20260716/hh=11")
+ val truncatedData = new Path(truncated, "data.csv")
+ val keptData = new Path(kept, "data.csv")
+ fileIO.writeFile(truncatedData, "1,20260715,10", false)
+ fileIO.writeFile(keptData, "2,20260716,11", false)
+
+ assert(new
PaimonFormatTable(table).truncatePartition(partitionRow(20260715, 10)))
+
+ assert(!fileIO.exists(truncatedData))
+ // The partition survives being emptied: its directory stays and so does
its registration,
+ // which is what makes TRUNCATE different from DROP PARTITION.
+ assert(fileIO.exists(truncated))
+ assert(gateway.partitions == registered)
+ assert(fileIO.exists(keptData))
+ } finally {
+ fileIO.delete(tablePath, true)
+ }
+ }
+
+ test("catalog-managed TRUNCATE PARTITION refuses a partition the catalog
does not know") {
+ val fileIO = LocalFileIO.create()
+ val tablePath =
+ new
Path(Files.createTempDirectory("catalog-partition-format-truncate-unknown").toUri)
+ val gateway = new InMemoryPartitionManager
+ val table = formatTableWithCatalogManagedPartitions(fileIO,
tablePath.toString, gateway)
+ try {
+ val awaitingRepair = new Path(tablePath, "dt=20260715/hh=10")
+ val data = new Path(awaitingRepair, "data.csv")
+ fileIO.writeFile(data, "1,20260715,10", false)
+
+ intercept[NoSuchPartitionException] {
+ new PaimonFormatTable(table).truncatePartition(partitionRow(20260715,
10))
+ }
+ intercept[NoSuchPartitionsException] {
+ new
PaimonFormatTable(table).truncatePartitions(Array(partitionRow(20260715, 10)))
+ }
+
+ // Data that MSCK REPAIR TABLE has yet to register is not this table's
to empty.
+ assert(fileIO.exists(data))
+ } finally {
+ fileIO.delete(tablePath, true)
+ }
+ }
+
+ test("catalog-managed TRUNCATE TABLE empties the registered partitions and
nothing else") {
+ val fileIO = LocalFileIO.create()
+ val tablePath =
+ new
Path(Files.createTempDirectory("catalog-partition-format-truncate-table").toUri)
+ val registered = Seq(partitionSpec(20260715, 10))
+ val gateway = new InMemoryPartitionManager(registered)
+ val table = formatTableWithCatalogManagedPartitions(fileIO,
tablePath.toString, gateway)
+ try {
+ val registeredData = new Path(new Path(tablePath, "dt=20260715/hh=10"),
"data.csv")
+ val awaitingRepairData = new Path(new Path(tablePath,
"dt=20260716/hh=11"), "data.csv")
+ fileIO.writeFile(registeredData, "1,20260715,10", false)
+ fileIO.writeFile(awaitingRepairData, "2,20260716,11", false)
+
+ assert(new PaimonFormatTable(table).truncateTable())
+
+ assert(!fileIO.exists(registeredData))
+ // The catalog says which partitions this table has, and the table reads
only those. A
+ // directory MSCK REPAIR TABLE has yet to register is not the table's to
empty.
+ assert(fileIO.exists(awaitingRepairData))
+ assert(gateway.partitions == registered)
+ } finally {
+ fileIO.delete(tablePath, true)
+ }
+ }
+
+ test("catalog-managed TRUNCATE PARTITION rejects a partition value that
would escape the table") {
+ val gateway = new InMemoryPartitionManager(Seq(Map("dt" -> "..")))
+ val sparkTable =
+ new PaimonFormatTable(
+ stringFormatTableWithCatalogManagedPartitions(gateway, onlyValueInPath
= true))
+
+ val error = intercept[IllegalArgumentException] {
+ sparkTable.truncatePartition(new
GenericInternalRow(Array[Any](UTF8String.fromString(".."))))
+ }
+
+ // The value is rejected while the directory is resolved, before the
catalog is asked whether
+ // the partition exists and so before anything is deleted.
+ assert(error.getMessage.contains(".."))
+ assert(gateway.lookupCalls == 0)
}
private def emptyGateway: FormatTablePartitionManager =
@@ -651,6 +749,7 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
private val storedPartitions = mutable.LinkedHashSet(initialPartitions: _*)
private var requests = Seq.empty[(Seq[Map[String, String]], Boolean)]
var lookupCalls = 0
+ var listCalls = 0
def createRequests: Seq[(Seq[Map[String, String]], Boolean)] =
synchronized(requests)
@@ -683,7 +782,12 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
}
override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] =
- throw new AssertionError("ADD must not enumerate catalog or filesystem
partitions")
+ synchronized {
+ listCalls += 1
+ val required = prefix.asScala.toSet
+ registeredPartitions(
+ storedPartitions.filter(spec =>
required.subsetOf(spec.toSet)).toSeq: _*)
+ }
}
private class TestPartitionAlreadyExistsException(val partition: Map[String,
String])
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
index bee92ed562..58c9537d7a 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
@@ -448,6 +448,39 @@ class CatalogManagedPartitionMsckRepairTest extends
PaimonSparkTestWithRestCatal
}
}
+ test("a measuring MSCK keeps the exact zero a TRUNCATE reported") {
+ val tableName = "msck_statistics_after_truncate"
+ val partition = "20260722"
+
+ withTable(tableName) {
+ createFormatTableWithCatalogManagedPartitions(tableName)
+ writeCsvPartition(tableName, partition, 22, "to-be-truncated")
+
+ val collectStatistics =
+
s"spark.paimon.${SparkConnectorOptions.FORMAT_TABLE_REPAIR_COLLECT_STATISTICS.key()}"
+ withSQLConf(collectStatistics -> "true") {
+ executeCatalogManagedRepair(s"MSCK REPAIR TABLE
paimon.$dbName0.$tableName")
+ }
+ assert(statisticsOf(tableName, partition).fileCount() == 1L)
+
+ sql(s"TRUNCATE TABLE paimon.$dbName0.$tableName PARTITION (dt =
'$partition')")
+ val truncated = statisticsOf(tableName, partition)
+ assert(truncated.fileCount() == 0L, truncated.toString)
+ assert(truncated.recordCount() == 0L, truncated.toString)
+
+ withSQLConf(collectStatistics -> "true") {
+ executeCatalogManagedRepair(s"MSCK REPAIR TABLE
paimon.$dbName0.$tableName")
+ }
+
+ // Measuring an empty partition confirms it is empty; it does not
un-measure it.
+ val remeasured = statisticsOf(tableName, partition)
+ assert(remeasured.fileCount() == 0L, remeasured.toString)
+ assert(remeasured.fileSizeInBytes() == 0L, remeasured.toString)
+ assert(remeasured.recordCount() == 0L, remeasured.toString)
+ assertPartitionState(tableName, Set(partition))
+ }
+ }
+
private def statisticsOf(tableName: String, partition: String): Partition =
paimonCatalog
.listPartitions(tableIdentifier(tableName))
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
index 652fb9be4d..dc523284bb 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
@@ -28,6 +28,7 @@ import org.apache.paimon.table.source.Split
import org.apache.paimon.utils.{CompressUtils, PartitionPathUtils}
import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.analysis.NoSuchPartitionException
import org.apache.spark.sql.connector.read.InputPartition
import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
@@ -144,6 +145,56 @@ abstract class FormatTableTestBase extends
PaimonHiveTestBase with AdaptiveSpark
}
}
+ test("Format table: truncate table") {
+ withTable("t") {
+ sql("CREATE TABLE t (id INT, p1 INT, p2 STRING) USING csv PARTITIONED BY
(p1, p2)")
+ sql("INSERT INTO t VALUES (1, 1, '1'), (2, 2, '1'), (3, 2, '2')")
+
+ sql("TRUNCATE TABLE t")
+
+ checkAnswer(sql("SELECT * FROM t"), Seq.empty)
+ // Emptying a table does not redefine which partitions it has
(SPARK-34418). Here they are
+ // the directories, and those stay.
+ checkAnswer(
+ sql("SHOW PARTITIONS t"),
+ Seq(Row("p1=1/p2=1"), Row("p1=2/p2=1"), Row("p1=2/p2=2")))
+ }
+ }
+
+ test("Format table: truncate partition") {
+ withTable("t") {
+ sql("CREATE TABLE t (id INT, p1 INT, p2 STRING) USING csv PARTITIONED BY
(p1, p2)")
+ sql("INSERT INTO t VALUES (1, 1, '1'), (2, 2, '1'), (3, 2, '2')")
+
+ sql("TRUNCATE TABLE t PARTITION (p1 = 2, p2 = '2')")
+ checkAnswer(sql("SELECT * FROM t"), Seq(Row(1, 1, "1"), Row(2, 2, "1")))
+
+ // A partial spec truncates the partitions it covers.
+ sql("TRUNCATE TABLE t PARTITION (p1 = 2)")
+ checkAnswer(sql("SELECT * FROM t"), Seq(Row(1, 1, "1")))
+
+ checkAnswer(
+ sql("SHOW PARTITIONS t"),
+ Seq(Row("p1=1/p2=1"), Row("p1=2/p2=1"), Row("p1=2/p2=2")))
+ }
+ }
+
+ test("Format table: truncate a partition the table does not have") {
+ withTable("t") {
+ sql("CREATE TABLE t (id INT, p1 INT, p2 STRING) USING csv PARTITIONED BY
(p1, p2)")
+ sql("INSERT INTO t VALUES (1, 1, '1')")
+
+ // A complete spec matching nothing is an error, as for any other Spark
table; a partial one
+ // matching nothing does nothing.
+ intercept[NoSuchPartitionException] {
+ sql("TRUNCATE TABLE t PARTITION (p1 = 9, p2 = '9')")
+ }
+ sql("TRUNCATE TABLE t PARTITION (p1 = 9)")
+
+ checkAnswer(sql("SELECT * FROM t"), Seq(Row(1, 1, "1")))
+ }
+ }
+
test("Format table: CTAS with partitioned table") {
withTable("t1", "t2") {
sql("CREATE TABLE t1 (id INT, p1 INT, p2 INT) USING csv PARTITIONED BY
(p1, p2)")