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 ef0b12e16a [spark] Support expire_changelogs procedure (#10062)
ef0b12e16a is described below
commit ef0b12e16ae71b8fed55a2c3c9ae10b5b436eb4b
Author: Wenchao Wu <[email protected]>
AuthorDate: Sat Oct 3 17:20:20 2026 +0800
[spark] Support expire_changelogs procedure (#10062)
---
docs/docs/spark/procedures.md | 1 +
docs/docs/spark/procedures/maintenance.md | 30 +++
.../org/apache/paimon/utils/ProcedureUtils.java | 29 +++
.../apache/paimon/table/ExpireChangelogImpl.java | 63 ++++-
.../apache/paimon/table/ChangelogExpireTest.java | 86 ++++++-
.../org/apache/paimon/spark/SparkProcedures.java | 2 +
.../spark/procedure/ExpireChangelogsProcedure.java | 157 ++++++++++++
.../procedure/ExpireChangelogsProcedureTest.scala | 265 +++++++++++++++++++++
8 files changed, 623 insertions(+), 10 deletions(-)
diff --git a/docs/docs/spark/procedures.md b/docs/docs/spark/procedures.md
index 947de28348..441033b332 100644
--- a/docs/docs/spark/procedures.md
+++ b/docs/docs/spark/procedures.md
@@ -65,6 +65,7 @@ Choose a group, then use the page contents to jump to a
procedure:
[`materialize_deletion_vectors`](./procedures/maintenance#materialize_deletion_vectors),
[`rescale`](./procedures/maintenance#rescale),
[`expire_snapshots`](./procedures/maintenance#expire_snapshots),
+[`expire_changelogs`](./procedures/maintenance#expire_changelogs),
[`expire_partitions`](./procedures/maintenance#expire_partitions),
[`remove_orphan_files`](./procedures/maintenance#remove_orphan_files),
[`remove_orphan_blobs`](./procedures/maintenance#remove_orphan_blobs),
diff --git a/docs/docs/spark/procedures/maintenance.md
b/docs/docs/spark/procedures/maintenance.md
index 366dd13e53..2153d68247 100644
--- a/docs/docs/spark/procedures/maintenance.md
+++ b/docs/docs/spark/procedures/maintenance.md
@@ -199,6 +199,36 @@ Expire snapshots.
CALL sys.expire_snapshots(table => 'default.T', retain_max => 10, options =>
'snapshot.expire.limit=1');
```
+## expire_changelogs
+
+Expire separated changelog files. Unspecified arguments fall back to table
properties
+(`changelog.num-retained.max` / `changelog.num-retained.min` /
`changelog.time-retained`, or the
+corresponding `snapshot.*` options when changelog options are not set, and
`snapshot.expire.limit`
+for `max_deletes`). `retain_min` and `retain_max` are applied against the
latest snapshot id, not
+the long-lived changelog count. The procedure returns
`deleted_changelogs_count`, the number of
+changelogs successfully deleted. A missing changelog, one skipped after a
file-skipper failure, or
+one whose changelog file is still present after a failed deletion, is not
included.
+
+**Arguments**
+
+- `table` (`STRING`, required): the target table identifier.
+- `retain_max` (`INT`, optional): the maximum number of completed changelogs
to retain.
+- `retain_min` (`INT`, optional): the minimum number of completed changelogs
to retain.
+- `older_than` (`STRING`, optional): timestamp before which changelogs will be
removed.
+- `max_deletes` (`INT`, optional): the maximum number of changelogs that can
be deleted at once.
+- `delete_all` (`BOOLEAN`, optional): when true, delete all separated
changelogs. Cannot be combined with `retain_max`, `retain_min`, `older_than`,
`max_deletes` or `options`.
+- `options` (`STRING`, optional): the additional dynamic options of the table.
These override stored table properties and are overridden by explicit procedure
arguments.
+
+```sql
+CALL sys.expire_changelogs(table => 'default.T', retain_max => 8);
+
+CALL sys.expire_changelogs(table => 'default.T', older_than => '2024-01-01
12:00:00', max_deletes => 2);
+
+CALL sys.expire_changelogs(table => 'default.T', options =>
'changelog.num-retained.max=8, changelog.num-retained.min=4');
+
+CALL sys.expire_changelogs(table => 'default.T', delete_all => true);
+```
+
## expire_partitions
Expire partitions.
diff --git
a/paimon-common/src/main/java/org/apache/paimon/utils/ProcedureUtils.java
b/paimon-common/src/main/java/org/apache/paimon/utils/ProcedureUtils.java
index d13cb3c6df..9e4f966beb 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/ProcedureUtils.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/ProcedureUtils.java
@@ -95,4 +95,33 @@ public class ProcedureUtils {
}
return builder;
}
+
+ public static ExpireConfig.Builder fillInChangelogOptions(
+ CoreOptions tableOptions,
+ Integer retainMax,
+ Integer retainMin,
+ String olderThanStr,
+ Integer maxDeletes) {
+
+ ExpireConfig.Builder builder = ExpireConfig.builder();
+ builder.snapshotRetainMax(tableOptions.snapshotNumRetainMax())
+ .snapshotRetainMin(tableOptions.snapshotNumRetainMin())
+ .snapshotTimeRetain(tableOptions.snapshotTimeRetain())
+ .snapshotMaxDeletes(tableOptions.snapshotExpireLimit())
+ .changelogRetainMax(
+
Optional.ofNullable(retainMax).orElse(tableOptions.changelogNumRetainMax()))
+ .changelogRetainMin(
+
Optional.ofNullable(retainMin).orElse(tableOptions.changelogNumRetainMin()))
+ .changelogMaxDeletes(
+
Optional.ofNullable(maxDeletes).orElse(tableOptions.snapshotExpireLimit()))
+ .changelogTimeRetain(tableOptions.changelogTimeRetain());
+ if (!StringUtils.isNullOrWhitespaceOnly(olderThanStr)) {
+ long olderThanMills =
+ DateTimeUtils.parseTimestampData(olderThanStr, 3,
TimeZone.getDefault())
+ .getMillisecond();
+ builder.changelogTimeRetain(
+ Duration.ofMillis(System.currentTimeMillis() -
olderThanMills));
+ }
+ return builder;
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
index b5d0af3954..fbb044c1a0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/ExpireChangelogImpl.java
@@ -21,6 +21,8 @@ package org.apache.paimon.table;
import org.apache.paimon.Changelog;
import org.apache.paimon.Snapshot;
import org.apache.paimon.consumer.ConsumerManager;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
import org.apache.paimon.manifest.ExpireFileEntry;
import org.apache.paimon.operation.ChangelogDeletion;
import org.apache.paimon.options.ExpireConfig;
@@ -149,6 +151,12 @@ public class ExpireChangelogImpl implements
ExpireSnapshots {
return expireUntil(earliestChangelogId, maxExclusive);
}
+ /**
+ * Expire changelogs in {@code [earliestId, endExclusiveId)}.
+ *
+ * @return number of changelogs successfully deleted. A missing changelog,
one skipped after a
+ * file-skipper failure, or one whose file remains after a failed
deletion, is not counted.
+ */
public int expireUntil(long earliestId, long endExclusiveId) {
if (LOG.isDebugEnabled()) {
LOG.debug("Changelog expire range is [{}, {})", earliestId,
endExclusiveId);
@@ -169,6 +177,7 @@ public class ExpireChangelogImpl implements ExpireSnapshots
{
}
skippingSnapshots.add(snapshotManager.earliestSnapshot());
Set<String> manifestSkippSet =
changelogDeletion.manifestSkippingSet(skippingSnapshots);
+ int deleted = 0;
for (long id = earliestId; id < endExclusiveId; id++) {
if (LOG.isDebugEnabled()) {
LOG.debug("Ready to delete changelog files from changelog
#{}", id);
@@ -193,34 +202,53 @@ public class ExpireChangelogImpl implements
ExpireSnapshots {
changelogDeletion.cleanDeletedDataFiles(changelog, skipper);
changelogDeletion.cleanUnusedManifests(changelog,
manifestSkippSet);
-
changelogManager.fileIO().deleteQuietly(changelogManager.longLivedChangelogPath(id));
+ if (deleteChangelogFile(
+ changelogManager.fileIO(),
changelogManager.longLivedChangelogPath(id))) {
+ deleted++;
+ }
}
changelogDeletion.cleanEmptyDirectories();
writeEarliestHintFile(endExclusiveId);
- return (int) (endExclusiveId - earliestId);
+ return deleted;
}
- /** expire all separated changelogs, only used by
ExpireChangelogsProcedure. */
+ /**
+ * Expire all separated changelogs.
+ *
+ * <p>The return type stays {@code void} so callers compiled against
{@code ()V}, including the
+ * Flink procedure, remain compatible. Use {@link
#expireAllDeletedCount()} when the deleted
+ * count is needed.
+ */
public void expireAll() {
+ expireAllDeletedCount();
+ }
+
+ /**
+ * Expire all separated changelogs.
+ *
+ * @return number of changelogs successfully deleted. A missing changelog,
one skipped after a
+ * file-skipper failure, or one whose file remains after a failed
deletion, is not counted.
+ */
+ public int expireAllDeletedCount() {
Long latestSnapshotId = snapshotManager.latestSnapshotId();
if (latestSnapshotId == null) {
// no snapshot, nothing to expire
- return;
+ return 0;
}
Long earliestSnapshotId = snapshotManager.earliestSnapshotId();
if (earliestSnapshotId == null) {
- return;
+ return 0;
}
Long latestChangelogId = changelogManager.latestLongLivedChangelogId();
if (latestChangelogId == null) {
- return;
+ return 0;
}
Long earliestChangelogId =
changelogManager.earliestLongLivedChangelogId();
if (earliestChangelogId == null) {
- return;
+ return 0;
}
LOG.info(
@@ -240,6 +268,7 @@ public class ExpireChangelogImpl implements ExpireSnapshots
{
skippingSnapshots.add(snapshotManager.snapshot(earliestSnapshotId));
Set<String> manifestSkippSet =
changelogDeletion.manifestSkippingSet(skippingSnapshots);
+ int deleted = 0;
for (long id = earliestChangelogId; id <= latestChangelogId; id++) {
LOG.info("Ready to delete changelog files from changelog #{}", id);
@@ -264,7 +293,10 @@ public class ExpireChangelogImpl implements
ExpireSnapshots {
changelogDeletion.cleanDeletedDataFiles(changelog, skipper);
changelogDeletion.cleanUnusedManifests(changelog,
manifestSkippSet);
-
changelogManager.fileIO().deleteQuietly(changelogManager.longLivedChangelogPath(id));
+ if (deleteChangelogFile(
+ changelogManager.fileIO(),
changelogManager.longLivedChangelogPath(id))) {
+ deleted++;
+ }
}
// try delete changelog hint file
@@ -276,6 +308,21 @@ public class ExpireChangelogImpl implements
ExpireSnapshots {
}
changelogDeletion.cleanEmptyDirectories();
+ return deleted;
+ }
+
+ /**
+ * Delete a changelog file and report whether it is actually gone. {@link
FileIO#deleteQuietly}
+ * swallows I/O failures, so a file that is still present is not a
successful removal.
+ */
+ static boolean deleteChangelogFile(FileIO fileIO, Path path) {
+ fileIO.deleteQuietly(path);
+ try {
+ return !fileIO.exists(path);
+ } catch (IOException e) {
+ LOG.warn("Failed to check whether changelog file {} still
exists.", path, e);
+ return false;
+ }
}
private void writeEarliestHintFile(long earliest) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/ChangelogExpireTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/ChangelogExpireTest.java
index 281dd0f1a6..fa08f69f7c 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/ChangelogExpireTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/ChangelogExpireTest.java
@@ -26,6 +26,7 @@ import org.apache.paimon.catalog.CatalogFactory;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.manifest.ManifestFileMeta;
import org.apache.paimon.manifest.ManifestList;
import org.apache.paimon.options.ExpireConfig;
@@ -184,13 +185,94 @@ public class ChangelogExpireTest extends
IndexFileExpireTableTest {
assertThat(fileIO.exists(changelogManager.longLivedChangelogPath(middleId))).isTrue();
fileIO.deleteQuietly(changelogManager.longLivedChangelogPath(middleId));
+ int expectedDeleted = 0;
+ for (long id = earliestChangelogId; id < latestChangelogId; id++) {
+ if (changelogManager.longLivedChangelogExists(id)) {
+ expectedDeleted++;
+ }
+ }
+ assertThat(expectedDeleted).isLessThan((int) (latestChangelogId -
earliestChangelogId));
+
ExpireChangelogImpl expire =
(ExpireChangelogImpl)
table.newExpireChangelog().config(expireConfig);
- // should not throw even though a middle changelog is missing
- assertThatCode(expire::expire).doesNotThrowAnyException();
+ // should not throw even though a middle changelog is missing, and the
count excludes it
+ assertThat(expire.expire()).isEqualTo(expectedDeleted);
// earliest should be advanced past the deleted range
assertThat(changelogManager.earliestLongLivedChangelogId()).isEqualTo(latestChangelogId);
+
assertThat(changelogManager.longLivedChangelogExists(latestChangelogId)).isTrue();
+ }
+
+ @Test
+ public void testDeleteChangelogFileRequiresActualRemoval() throws
Exception {
+ LocalFileIO fileIO = new LocalFileIO();
+ java.nio.file.Path tempDir =
java.nio.file.Files.createTempDirectory("changelog-delete");
+ Path dir = new Path(tempDir.toString());
+ try {
+ Path removed = new Path(dir, "changelog-1");
+ fileIO.writeFile(removed, "{}", false);
+ assertThat(ExpireChangelogImpl.deleteChangelogFile(fileIO,
removed)).isTrue();
+ assertThat(fileIO.exists(removed)).isFalse();
+
+ Path blocked = new Path(dir, "changelog-2");
+ assertThat(fileIO.mkdirs(blocked)).isTrue();
+ fileIO.writeFile(new Path(blocked, "keep"), "x", false);
+ assertThat(ExpireChangelogImpl.deleteChangelogFile(fileIO,
blocked)).isFalse();
+ assertThat(fileIO.exists(blocked)).isTrue();
+ } finally {
+ fileIO.delete(dir, true);
+ }
+ }
+
+ @Test
+ public void testExpireAllDeletedCountSkipsMissingChangelog() throws
Exception {
+ StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder();
+ StreamTableWrite write = writeBuilder.newWrite();
+ StreamTableCommit commit = writeBuilder.newCommit();
+ for (int i = 1; i <= 10; i++) {
+ write(write, createRow(1, 0, i, i * 10));
+ commit.commit(i, write.prepareCommit(true, i));
+ }
+ write.close();
+ commit.close();
+
+ SnapshotManager snapshotManager = table.snapshotManager();
+ long latestSnapshotId = snapshotManager.latestSnapshotId();
+
+ // changelogRetainMax > snapshotRetainMax to ensure
changelogDecoupled=true
+ ExpireConfig expireConfig =
+ ExpireConfig.builder()
+ .changelogRetainMax((int) latestSnapshotId)
+ .changelogRetainMin(1)
+ .changelogTimeRetain(Duration.ofMillis(0))
+ .snapshotRetainMax(1)
+ .snapshotRetainMin(1)
+ .build();
+ ExpireSnapshotsImpl expireSnapshots =
+ (ExpireSnapshotsImpl)
table.newExpireSnapshots().config(expireConfig);
+ expireSnapshots.expire();
+
+ ChangelogManager changelogManager = table.changelogManager();
+ FileIO fileIO = table.fileIO();
+ long latestChangelogId = changelogManager.latestLongLivedChangelogId();
+ long earliestChangelogId =
changelogManager.earliestLongLivedChangelogId();
+
+ long middleId = (earliestChangelogId + latestChangelogId) / 2;
+
assertThat(fileIO.exists(changelogManager.longLivedChangelogPath(middleId))).isTrue();
+
fileIO.deleteQuietly(changelogManager.longLivedChangelogPath(middleId));
+
+ int existing = 0;
+ for (long id = earliestChangelogId; id <= latestChangelogId; id++) {
+ if (changelogManager.longLivedChangelogExists(id)) {
+ existing++;
+ }
+ }
+ assertThat(existing).isEqualTo((int) (latestChangelogId -
earliestChangelogId));
+
+ ExpireChangelogImpl expire = (ExpireChangelogImpl)
table.newExpireChangelog();
+ assertThat(expire.expireAllDeletedCount()).isEqualTo(existing);
+ assertThat(changelogManager.latestLongLivedChangelogId()).isNull();
+ assertThat(changelogManager.earliestLongLivedChangelogId()).isNull();
}
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
index 18bb328375..aebc19389e 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
@@ -37,6 +37,7 @@ import org.apache.paimon.spark.procedure.DeleteTagProcedure;
import org.apache.paimon.spark.procedure.DropFunctionProcedure;
import org.apache.paimon.spark.procedure.DropGlobalIndexProcedure;
import org.apache.paimon.spark.procedure.DropPolicyProcedure;
+import org.apache.paimon.spark.procedure.ExpireChangelogsProcedure;
import org.apache.paimon.spark.procedure.ExpirePartitionsProcedure;
import org.apache.paimon.spark.procedure.ExpireSnapshotsProcedure;
import org.apache.paimon.spark.procedure.ExpireTagsProcedure;
@@ -129,6 +130,7 @@ public class SparkProcedures {
procedureBuilders.put(
"remove_unexisting_manifests",
RemoveUnexistingManifestsProcedure::builder);
procedureBuilders.put("expire_snapshots",
ExpireSnapshotsProcedure::builder);
+ procedureBuilders.put("expire_changelogs",
ExpireChangelogsProcedure::builder);
procedureBuilders.put("expire_partitions",
ExpirePartitionsProcedure::builder);
procedureBuilders.put("repair", RepairProcedure::builder);
procedureBuilders.put("repair_earliest_snapshot",
RepairEarliestSnapshotProcedure::builder);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ExpireChangelogsProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ExpireChangelogsProcedure.java
new file mode 100644
index 0000000000..0f696e234e
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ExpireChangelogsProcedure.java
@@ -0,0 +1,157 @@
+/*
+ * 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.spark.procedure;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.ExpireConfig;
+import org.apache.paimon.table.ExpireChangelogImpl;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.ProcedureUtils;
+import org.apache.paimon.utils.StringUtils;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import java.util.HashMap;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.spark.sql.types.DataTypes.BooleanType;
+import static org.apache.spark.sql.types.DataTypes.IntegerType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** A procedure to expire changelogs. */
+public class ExpireChangelogsProcedure extends BaseProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("table", StringType),
+ ProcedureParameter.optional("retain_max", IntegerType),
+ ProcedureParameter.optional("retain_min", IntegerType),
+ ProcedureParameter.optional("older_than", StringType),
+ ProcedureParameter.optional("max_deletes", IntegerType),
+ ProcedureParameter.optional("delete_all", BooleanType),
+ ProcedureParameter.optional("options", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField(
+ "deleted_changelogs_count", IntegerType,
false, Metadata.empty())
+ });
+
+ private ExpireChangelogsProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ Identifier tableIdent = toIdentifier(args.getString(0),
PARAMETERS[0].name());
+ Integer retainMax = args.isNullAt(1) ? null : args.getInt(1);
+ Integer retainMin = args.isNullAt(2) ? null : args.getInt(2);
+ String olderThanStr = args.isNullAt(3) ? null : args.getString(3);
+ Integer maxDeletes = args.isNullAt(4) ? null : args.getInt(4);
+ boolean deleteAll = !args.isNullAt(5) && args.getBoolean(5);
+ String options = args.isNullAt(6) ? null : args.getString(6);
+
+ return modifyPaimonTable(
+ tableIdent,
+ table -> {
+ checkArgument(
+ table instanceof FileStoreTable,
+ "Only FileStoreTable supports expire_changelogs
procedure. The table type is '%s'.",
+ table.getClass().getName());
+
+ if (deleteAll) {
+ checkArgument(
+ retainMax == null
+ && retainMin == null
+ && olderThanStr == null
+ && maxDeletes == null
+ &&
StringUtils.isNullOrWhitespaceOnly(options),
+ "delete_all cannot be used with retain_max,
retain_min, older_than, max_deletes or options.");
+ ExpireChangelogImpl expireChangelogs =
+ (ExpireChangelogImpl)
table.newExpireChangelog();
+ int deleted = expireChangelogs.expireAllDeletedCount();
+ return new InternalRow[] {newInternalRow(deleted)};
+ }
+
+ HashMap<String, String> dynamicOptions = new HashMap<>();
+ ProcedureUtils.putAllOptions(dynamicOptions, options);
+ // Explicit arguments override options. Merge them before
copy so schema
+ // validation sees the final config, not an intermediate
one.
+ overrideWithExplicitArguments(dynamicOptions, retainMax,
retainMin, maxDeletes);
+ table = table.copy(dynamicOptions);
+ ExpireChangelogImpl expireChangelogs =
+ (ExpireChangelogImpl) table.newExpireChangelog();
+ CoreOptions tableOptions = ((FileStoreTable)
table).store().options();
+ ExpireConfig.Builder builder =
+ ProcedureUtils.fillInChangelogOptions(
+ tableOptions, retainMax, retainMin,
olderThanStr, maxDeletes);
+ int deleted =
expireChangelogs.config(builder.build()).expire();
+ return new InternalRow[] {newInternalRow(deleted)};
+ });
+ }
+
+ private static void overrideWithExplicitArguments(
+ HashMap<String, String> dynamicOptions,
+ Integer retainMax,
+ Integer retainMin,
+ Integer maxDeletes) {
+ if (retainMax != null) {
+ dynamicOptions.put(
+ CoreOptions.CHANGELOG_NUM_RETAINED_MAX.key(),
String.valueOf(retainMax));
+ }
+ if (retainMin != null) {
+ dynamicOptions.put(
+ CoreOptions.CHANGELOG_NUM_RETAINED_MIN.key(),
String.valueOf(retainMin));
+ }
+ if (maxDeletes != null) {
+ dynamicOptions.put(CoreOptions.SNAPSHOT_EXPIRE_LIMIT.key(),
String.valueOf(maxDeletes));
+ }
+ }
+
+ public static ProcedureBuilder builder() {
+ return new BaseProcedure.Builder<ExpireChangelogsProcedure>() {
+ @Override
+ public ExpireChangelogsProcedure doBuild() {
+ return new ExpireChangelogsProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "ExpireChangelogsProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ExpireChangelogsProcedureTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ExpireChangelogsProcedureTest.scala
new file mode 100644
index 0000000000..bc0b12f638
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ExpireChangelogsProcedureTest.scala
@@ -0,0 +1,265 @@
+/*
+ * 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.spark.procedure
+
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.utils.{ChangelogManager, SnapshotManager}
+
+import org.apache.spark.sql.Row
+import org.assertj.core.api.Assertions.assertThat
+
+import java.sql.Timestamp
+
+class ExpireChangelogsProcedureTest extends PaimonSparkTestBase {
+
+ test("Paimon Procedure: expire changelogs") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val snapshotManager = table.snapshotManager
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkSnapshots(snapshotManager, 7, 10)
+ checkChangelogs(changelogManager, 1, 6)
+
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', retain_max =>
8)"),
+ Row(2) :: Nil)
+ checkChangelogs(changelogManager, 3, 6)
+
+ val ts = new Timestamp(snapshotManager.latestSnapshot().timeMillis)
+ checkAnswer(
+ spark.sql(
+ s"CALL sys.expire_changelogs(table => 'test.T', older_than =>
'${ts.toString}', max_deletes => 1)"),
+ Row(1) :: Nil)
+ checkChangelogs(changelogManager, 4, 6)
+
+ checkAnswer(
+ spark.sql(
+ s"CALL sys.expire_changelogs(table => 'test.T', older_than =>
'${ts.toString}', retain_min => 6)"),
+ Row(1) :: Nil)
+ checkChangelogs(changelogManager, 5, 6)
+
+ checkAnswer(
+ spark.sql(s"CALL sys.expire_changelogs(table => 'test.T', older_than =>
'${ts.toString}')"),
+ Row(1) :: Nil)
+ checkChangelogs(changelogManager, 6, 6)
+
+ for (i <- 10 until 12) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkSnapshots(snapshotManager, 9, 12)
+ checkChangelogs(changelogManager, 6, 8)
+
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', retain_max =>
4)"),
+ Row(2) :: Nil)
+ checkChangelogs(changelogManager, 8, 8)
+
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', delete_all =>
true)"),
+ Row(1) :: Nil)
+ checkAllDeleted(changelogManager)
+ }
+
+ test("Paimon Procedure: expire changelogs load table property first") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val snapshotManager = table.snapshotManager
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkSnapshots(snapshotManager, 7, 10)
+ checkChangelogs(changelogManager, 1, 6)
+
+ // Commits already honor changelog.num-retained.max=10, so empty CALL
would delete 0.
+ // Tighten table properties and verify the procedure reads them.
+ spark.sql("""
+ |ALTER TABLE T SET TBLPROPERTIES (
+ | 'changelog.num-retained.max' = '8',
+ | 'changelog.num-retained.min' = '4'
+ |)
+ |""".stripMargin)
+
+ checkAnswer(spark.sql("CALL sys.expire_changelogs(table => 'test.T')"),
Row(2) :: Nil)
+ checkChangelogs(changelogManager, 3, 6)
+ }
+
+ test("Paimon Procedure: expire changelogs add options parameter") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val snapshotManager = table.snapshotManager
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkSnapshots(snapshotManager, 7, 10)
+ checkChangelogs(changelogManager, 1, 6)
+
+ checkAnswer(spark.sql("CALL sys.expire_changelogs(table => 'test.T')"),
Row(0) :: Nil)
+ checkChangelogs(changelogManager, 1, 6)
+
+ checkAnswer(
+ spark.sql(
+ "CALL sys.expire_changelogs(table => 'test.T', options =>
'changelog.num-retained.max=8, changelog.num-retained.min=4')"),
+ Row(2) :: Nil)
+ checkChangelogs(changelogManager, 3, 6)
+ }
+
+ test("Paimon Procedure: expire changelogs explicit args override options") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val snapshotManager = table.snapshotManager
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkSnapshots(snapshotManager, 7, 10)
+ checkChangelogs(changelogManager, 1, 6)
+
+ // options alone would be max=2 < table min=4 and fail validation.
+ // retain_max overrides it, so the validated config is min=4, max=8.
+ checkAnswer(
+ spark.sql(
+ "CALL sys.expire_changelogs(table => 'test.T', retain_max => 8,
options => 'changelog.num-retained.max=2')"),
+ Row(2) :: Nil)
+ checkChangelogs(changelogManager, 3, 6)
+ }
+
+ test("Paimon Procedure: expire changelogs without separated changelogs") {
+ spark.sql("CREATE TABLE T (word STRING, cnt INT)")
+ spark.sql("INSERT INTO T VALUES ('a', 1)")
+
+ checkAnswer(spark.sql("CALL sys.expire_changelogs(table => 'test.T')"),
Row(0) :: Nil)
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', delete_all =>
true)"),
+ Row(0) :: Nil)
+ }
+
+ test("Paimon Procedure: expire changelogs retainMax retainMin value check") {
+ createInputChangelogTable()
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+
+ assertThrows[IllegalArgumentException] {
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', retain_max =>
2, retain_min => 3)")
+ }
+ }
+
+ test("Paimon Procedure: expire changelogs skips missing changelog") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkChangelogs(changelogManager, 1, 6)
+
+ // retain_max 8 deletes changelogs 1 and 2; a missing id 2 must not be
counted
+ val missingId = 2L
+ assertThat(changelogManager.longLivedChangelogExists(missingId)).isTrue()
+
changelogManager.fileIO.deleteQuietly(changelogManager.longLivedChangelogPath(missingId))
+
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', retain_max =>
8)"),
+ Row(1) :: Nil)
+ checkChangelogs(changelogManager, 3, 6)
+ }
+
+ test("Paimon Procedure: expire changelogs delete_all skips missing
changelog") {
+ createInputChangelogTable()
+
+ val table = loadTable("T")
+ val changelogManager = table.changelogManager
+
+ for (i <- 0 until 10) {
+ spark.sql(s"INSERT INTO T VALUES ('$i', $i)")
+ }
+ checkChangelogs(changelogManager, 1, 6)
+
+ val missingId = 3L
+ assertThat(changelogManager.longLivedChangelogExists(missingId)).isTrue()
+
changelogManager.fileIO.deleteQuietly(changelogManager.longLivedChangelogPath(missingId))
+
+ checkAnswer(
+ spark.sql("CALL sys.expire_changelogs(table => 'test.T', delete_all =>
true)"),
+ Row(5) :: Nil)
+ checkAllDeleted(changelogManager)
+ }
+
+ test("Paimon Procedure: expire changelogs delete_all cannot mix with other
arguments") {
+ createInputChangelogTable()
+ spark.sql("INSERT INTO T VALUES ('0', 0)")
+
+ assertThrows[IllegalArgumentException] {
+ spark.sql(
+ "CALL sys.expire_changelogs(table => 'test.T', delete_all => true,
retain_max => 8)")
+ }
+ assertThrows[IllegalArgumentException] {
+ spark.sql(
+ "CALL sys.expire_changelogs(table => 'test.T', delete_all => true,
options => 'changelog.num-retained.max=8')")
+ }
+ }
+
+ private def createInputChangelogTable(): Unit = {
+ spark.sql("""
+ |CREATE TABLE T (word STRING, cnt INT)
+ |TBLPROPERTIES (
+ | 'primary-key' = 'word',
+ | 'bucket' = '1',
+ | 'num-sorted-run.compaction-trigger' = '9999',
+ | 'changelog-producer' = 'input',
+ | 'snapshot.num-retained.min' = '4',
+ | 'snapshot.num-retained.max' = '4',
+ | 'changelog.num-retained.min' = '4',
+ | 'changelog.num-retained.max' = '10'
+ |)
+ |""".stripMargin)
+ }
+
+ private def checkSnapshots(sm: SnapshotManager, earliest: Int, latest: Int):
Unit = {
+ assertThat(sm.snapshotCount).isEqualTo(latest - earliest + 1)
+ assertThat(sm.earliestSnapshotId).isEqualTo(earliest)
+ assertThat(sm.latestSnapshotId).isEqualTo(latest)
+ }
+
+ private def checkChangelogs(cm: ChangelogManager, earliest: Int, latest:
Int): Unit = {
+ assertThat(cm.earliestLongLivedChangelogId).isEqualTo(earliest)
+ assertThat(cm.latestLongLivedChangelogId).isEqualTo(latest)
+ }
+
+ private def checkAllDeleted(cm: ChangelogManager): Unit = {
+ assertThat(cm.latestLongLivedChangelogId).isNull()
+ assertThat(cm.earliestLongLivedChangelogId).isNull()
+ }
+}