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 1069106dd5 [flink] Support remove_orphan_blobs action and procedure. 
(#10014)
1069106dd5 is described below

commit 1069106dd59beb7d4572230f2c680ebd3aa17ec7
Author: Wenchao Wu <[email protected]>
AuthorDate: Tue Sep 22 11:28:20 2026 +0800

    [flink] Support remove_orphan_blobs action and procedure. (#10014)
---
 docs/docs/flink/procedures.md                      |   2 +-
 docs/docs/flink/procedures/repair.md               |  67 ++
 docs/docs/primary-key-table/blob-storage.md        |   5 +-
 .../procedure/RemoveOrphanBlobsProcedure.java      | 132 +++
 .../flink/RemoveOrphanBlobsActionITCase.java       |  30 +
 .../flink/RemoveOrphanBlobsActionITCase.java       |  25 +
 .../flink/action/RemoveOrphanBlobsAction.java      |  88 ++
 .../action/RemoveOrphanBlobsActionFactory.java     |  98 +++
 .../orphan/FlinkManagedBlobOrphanFilesClean.java   | 965 +++++++++++++++++++++
 .../paimon/flink/orphan/FlinkOrphanFilesClean.java |   4 +
 .../procedure/RemoveOrphanBlobsProcedure.java      | 120 +++
 .../services/org.apache.paimon.factories.Factory   |   2 +
 .../paimon/flink/action/ActionJobCoverageTest.java |   5 +-
 .../action/RemoveOrphanBlobsActionITCase.java      |  22 +
 .../action/RemoveOrphanBlobsActionITCaseBase.java  | 935 ++++++++++++++++++++
 .../action/RemoveOrphanFilesActionITCaseBase.java  |  24 +
 16 files changed, 2520 insertions(+), 4 deletions(-)

diff --git a/docs/docs/flink/procedures.md b/docs/docs/flink/procedures.md
index 831813994d..b248d8ab1c 100644
--- a/docs/docs/flink/procedures.md
+++ b/docs/docs/flink/procedures.md
@@ -72,5 +72,5 @@ defaults are specific to the procedure.
 | [Table Operations](./procedures/table-operations) | 
[`merge_into`](./procedures/table-operations#merge_into), 
[`data_evolution_merge_into`](./procedures/table-operations#data_evolution_merge_into),
 [`migrate_database`](./procedures/table-operations#migrate_database), 
[`migrate_table`](./procedures/table-operations#migrate_table), 
[`clone`](./procedures/table-operations#clone), 
[`copy_files`](./procedures/table-operations#copy_files), 
[`alter_column_default_value`](./procedures/table-op [...]
 | [Indexes and Search](./procedures/indexes) | 
[`create_global_index`](./procedures/indexes#create_global_index), 
[`drop_global_index`](./procedures/indexes#drop_global_index), 
[`full_text_search`](./procedures/indexes#full_text_search), 
[`vector_search`](./procedures/indexes#vector_search), 
[`rewrite_file_index`](./procedures/indexes#rewrite_file_index) |
 | [Consumers and Query Service](./procedures/consumers) | 
[`reset_consumer`](./procedures/consumers#reset_consumer), 
[`clear_consumers`](./procedures/consumers#clear_consumers), 
[`query_service`](./procedures/consumers#query_service) |
-| [Cleanup and Repair](./procedures/repair) | 
[`remove_orphan_files`](./procedures/repair#remove_orphan_files), 
[`remove_unexisting_files`](./procedures/repair#remove_unexisting_files), 
[`remove_unexisting_manifests`](./procedures/repair#remove_unexisting_manifests),
 [`repair`](./procedures/repair#repair), 
[`repair_earliest_snapshot`](./procedures/repair#repair_earliest_snapshot) |
+| [Cleanup and Repair](./procedures/repair) | 
[`remove_orphan_files`](./procedures/repair#remove_orphan_files), 
[`remove_orphan_blobs`](./procedures/repair#remove_orphan_blobs), 
[`remove_unexisting_files`](./procedures/repair#remove_unexisting_files), 
[`remove_unexisting_manifests`](./procedures/repair#remove_unexisting_manifests),
 [`repair`](./procedures/repair#repair), 
[`repair_earliest_snapshot`](./procedures/repair#repair_earliest_snapshot) |
 | [Views and Functions](./procedures/catalog) | 
[`alter_view_dialect`](./procedures/catalog#alter_view_dialect), 
[`create_function`](./procedures/catalog#create_function), 
[`alter_function`](./procedures/catalog#alter_function), 
[`drop_function`](./procedures/catalog#drop_function) |
diff --git a/docs/docs/flink/procedures/repair.md 
b/docs/docs/flink/procedures/repair.md
index 84effe5f2b..cbbf919ec9 100644
--- a/docs/docs/flink/procedures/repair.md
+++ b/docs/docs/flink/procedures/repair.md
@@ -90,6 +90,73 @@ CALL sys.remove_orphan_files(
 );
 ```
 
+This procedure does not delete primary-key `.managed.blob` packs. Use 
[`remove_orphan_blobs`](#remove_orphan_blobs).
+
+## remove_orphan_blobs
+
+Remove unreferenced primary-key `.managed.blob` packs.
+
+**Arguments**
+
+- `table`: the target table identifier. Cannot be empty, you can use 
`database_name.*` to clean the whole database.
+
+- `olderThan`: an absolute timestamp cutoff. Only packs whose modification 
time is earlier than this timestamp are candidates. The default cutoff is 1 day 
before the procedure starts.
+
+- `dryRun`: when true, calculate the candidate file count and total bytes 
without deleting files. The procedure returns aggregate counts, not individual 
pack paths. Default is false.
+
+- `parallelism`: per-table concurrency. In `distributed` mode this is the 
Flink task parallelism of each table job. In `local` mode this is the per-table 
file-operation thread limit (default: the number of processors available to the 
Java virtual machine). For `database_name.*`, `distributed` mode runs tables 
one after another, so cluster concurrency stays within this per-table value; 
`local` mode may run several tables at once, so total threads can exceed this 
value.
+
+- `mode`: The mode of remove orphan blob procedure (`local` or `distributed`). 
By default is `distributed`.
+
+**Syntax**
+
+```sql
+-- Use named argument
+CALL [catalog.]sys.remove_orphan_blobs(
+    `table` => 'identifier',
+    older_than => 'olderThan',
+    dry_run => 'dryRun',
+    parallelism => parallelism,
+    mode => 'mode'
+);
+
+-- Use indexed argument
+CALL [catalog.]sys.remove_orphan_blobs('identifier');
+
+CALL [catalog.]sys.remove_orphan_blobs('identifier', 'olderThan');
+
+CALL [catalog.]sys.remove_orphan_blobs('identifier', 'olderThan', 'dryRun');
+
+CALL [catalog.]sys.remove_orphan_blobs('identifier', 'olderThan', 
'dryRun','parallelism');
+
+CALL [catalog.]sys.remove_orphan_blobs('identifier', 'olderThan', 
'dryRun','parallelism','mode');
+```
+
+**Example**
+
+```sql
+CALL sys.remove_orphan_blobs(`table` => 'default.T', older_than => '2023-10-31 
12:00:00');
+
+CALL sys.remove_orphan_blobs(`table` => 'default.*', older_than => '2023-10-31 
12:00:00');
+
+CALL sys.remove_orphan_blobs(`table` => 'default.T', older_than => '2023-10-31 
12:00:00', dry_run => true);
+
+CALL sys.remove_orphan_blobs(
+    `table` => 'default.T',
+    older_than => '2023-10-31 12:00:00',
+    dry_run => false,
+    parallelism => 5
+);
+
+CALL sys.remove_orphan_blobs(
+    `table` => 'default.T',
+    older_than => '2023-10-31 12:00:00',
+    dry_run => false,
+    parallelism => 5,
+    mode => 'local'
+);
+```
+
 ## remove_unexisting_files
 
 Procedure to remove unexisting data files from manifest entries. See [Java 
docs](https://paimon.apache.org/docs/master/api/java/org/apache/paimon/flink/action/RemoveUnexistingFilesAction.html)
 for detailed use cases. Arguments:
diff --git a/docs/docs/primary-key-table/blob-storage.md 
b/docs/docs/primary-key-table/blob-storage.md
index e7fd8f21f3..dff5e8f83c 100644
--- a/docs/docs/primary-key-table/blob-storage.md
+++ b/docs/docs/primary-key-table/blob-storage.md
@@ -259,8 +259,9 @@ extra files because more than one retained data file can 
reference the same pack
 ## Garbage Collection
 
 Unreferenced `.managed.blob` packs are reclaimed by managed blob orphan 
cleanup.
-Local cleanup is `LocalManagedBlobOrphanFilesClean`; Spark exposes the same 
cleanup as
-[`remove_orphan_blobs`](../spark/procedures/maintenance#remove_orphan_blobs).
+Local cleanup is `LocalManagedBlobOrphanFilesClean`. Spark exposes the same 
cleanup as
+[`remove_orphan_blobs`](../spark/procedures/maintenance#remove_orphan_blobs);
+Flink exposes it as 
[`remove_orphan_blobs`](../flink/procedures/repair#remove_orphan_blobs).
 The cleaner reads every retained data file's `.blobref` sidecar across 
snapshots, tags, and
 branches, then deletes packs that are not referenced and whose modification 
time is earlier than the absolute
 `older_than` cutoff (1 day before the run starts by default).
diff --git 
a/paimon-flink/paimon-flink-1.18/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
 
b/paimon-flink/paimon-flink-1.18/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
new file mode 100644
index 0000000000..50ee675832
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-1.18/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
@@ -0,0 +1,132 @@
+/*
+ * 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.procedure;
+
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean;
+import org.apache.paimon.operation.CleanOrphanFilesResult;
+import org.apache.paimon.operation.LocalManagedBlobOrphanFilesClean;
+
+import org.apache.flink.table.procedure.ProcedureContext;
+
+import java.util.Locale;
+
+import static 
org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean.validateParallelism;
+import static org.apache.paimon.operation.OrphanFilesClean.olderThanMillis;
+
+/**
+ * Remove orphan managed BLOB packs procedure. Usage:
+ *
+ * <pre><code>
+ *  CALL sys.remove_orphan_blobs('tableId')
+ *
+ *  CALL sys.remove_orphan_blobs('tableId', '2023-12-31 23:59:59')
+ *
+ *  CALL sys.remove_orphan_blobs('databaseName.*', '2023-12-31 23:59:59')
+ * </code></pre>
+ */
+public class RemoveOrphanBlobsProcedure extends ProcedureBase {
+
+    public static final String IDENTIFIER = "remove_orphan_blobs";
+
+    public String[] call(ProcedureContext procedureContext, String tableId) 
throws Exception {
+        return call(procedureContext, tableId, "");
+    }
+
+    public String[] call(ProcedureContext procedureContext, String tableId, 
String olderThan)
+            throws Exception {
+        return call(procedureContext, tableId, olderThan, false);
+    }
+
+    public String[] call(
+            ProcedureContext procedureContext, String tableId, String 
olderThan, boolean dryRun)
+            throws Exception {
+        return call(procedureContext, tableId, olderThan, dryRun, null);
+    }
+
+    public String[] call(
+            ProcedureContext procedureContext,
+            String tableId,
+            String olderThan,
+            boolean dryRun,
+            Integer parallelism)
+            throws Exception {
+        return call(procedureContext, tableId, olderThan, dryRun, parallelism, 
null);
+    }
+
+    public String[] call(
+            ProcedureContext procedureContext,
+            String tableId,
+            String olderThan,
+            boolean dryRun,
+            Integer parallelism,
+            String mode)
+            throws Exception {
+        validateParallelism(parallelism);
+        Identifier identifier = Identifier.fromString(tableId);
+        String databaseName = identifier.getDatabaseName();
+        String tableName = identifier.getObjectName();
+        if (mode == null) {
+            mode = "DISTRIBUTED";
+        }
+
+        CleanOrphanFilesResult result;
+        try {
+            switch (mode.toUpperCase(Locale.ROOT)) {
+                case "DISTRIBUTED":
+                    result =
+                            FlinkManagedBlobOrphanFilesClean.executeDatabase(
+                                    procedureContext.getExecutionEnvironment(),
+                                    catalog,
+                                    olderThanMillis(olderThan),
+                                    dryRun,
+                                    parallelism,
+                                    databaseName,
+                                    tableName);
+                    break;
+                case "LOCAL":
+                    result =
+                            LocalManagedBlobOrphanFilesClean.executeDatabase(
+                                    catalog,
+                                    databaseName,
+                                    tableName,
+                                    olderThanMillis(olderThan),
+                                    parallelism,
+                                    dryRun);
+                    break;
+                default:
+                    throw new IllegalArgumentException(
+                            "Unknown mode: "
+                                    + mode
+                                    + ". Only 'DISTRIBUTED' and 'LOCAL' are 
supported.");
+            }
+            return new String[] {
+                String.valueOf(result.getDeletedFileCount()),
+                String.valueOf(result.getDeletedFileTotalLenInBytes())
+            };
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-1.18/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
 
b/paimon-flink/paimon-flink-1.18/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
new file mode 100644
index 0000000000..c987d9d6c8
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-1.18/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
@@ -0,0 +1,30 @@
+/*
+ * 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;
+
+import org.apache.paimon.flink.action.RemoveOrphanBlobsAction;
+import org.apache.paimon.flink.action.RemoveOrphanBlobsActionITCaseBase;
+
+/** IT cases for {@link RemoveOrphanBlobsAction} in Flink 1.18. */
+public class RemoveOrphanBlobsActionITCase extends 
RemoveOrphanBlobsActionITCaseBase {
+
+    protected boolean supportNamedArgument() {
+        return false;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-1.19/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
 
b/paimon-flink/paimon-flink-1.19/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
new file mode 100644
index 0000000000..16fd381355
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-1.19/src/test/java/org/apache/paimon/flink/RemoveOrphanBlobsActionITCase.java
@@ -0,0 +1,25 @@
+/*
+ * 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;
+
+import org.apache.paimon.flink.action.RemoveOrphanBlobsAction;
+import org.apache.paimon.flink.action.RemoveOrphanBlobsActionITCaseBase;
+
+/** IT cases for {@link RemoveOrphanBlobsAction} in Flink 1.19. */
+public class RemoveOrphanBlobsActionITCase extends 
RemoveOrphanBlobsActionITCaseBase {}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsAction.java
new file mode 100644
index 0000000000..700d0c7146
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsAction.java
@@ -0,0 +1,88 @@
+/*
+ * 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.action;
+
+import javax.annotation.Nullable;
+
+import java.util.Map;
+
+import static 
org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean.executeDatabase;
+import static 
org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean.validateParallelism;
+import static org.apache.paimon.operation.OrphanFilesClean.olderThanMillis;
+
+/** Action to remove unreferenced primary-key managed BLOB packs. */
+public class RemoveOrphanBlobsAction extends ActionBase {
+
+    private final String databaseName;
+    @Nullable private final String tableName;
+    @Nullable private final Integer parallelism;
+
+    private String olderThan = null;
+    private boolean dryRun = false;
+
+    public RemoveOrphanBlobsAction(
+            String databaseName,
+            @Nullable String tableName,
+            @Nullable String parallelism,
+            Map<String, String> catalogConfig) {
+        super(catalogConfig);
+        this.databaseName = databaseName;
+        this.tableName = tableName;
+        this.parallelism = parseParallelism(parallelism);
+    }
+
+    public void olderThan(String olderThan) {
+        this.olderThan = olderThan;
+    }
+
+    public void dryRun() {
+        this.dryRun = true;
+    }
+
+    @Override
+    public void run() throws Exception {
+        executeDatabase(
+                env,
+                catalog,
+                olderThanMillis(olderThan),
+                dryRun,
+                parallelism,
+                databaseName,
+                tableName);
+    }
+
+    @Nullable
+    static Integer parseParallelism(@Nullable String parallelism) {
+        if (parallelism == null) {
+            return null;
+        }
+
+        final int parsed;
+        try {
+            parsed = Integer.parseInt(parallelism);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Parallelism must be a positive integer, but was 
'%s'.", parallelism),
+                    e);
+        }
+        validateParallelism(parsed);
+        return parsed;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionFactory.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionFactory.java
new file mode 100644
index 0000000000..ed39315e69
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionFactory.java
@@ -0,0 +1,98 @@
+/*
+ * 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.action;
+
+import java.util.Optional;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Factory to create {@link RemoveOrphanBlobsAction}. */
+public class RemoveOrphanBlobsActionFactory implements ActionFactory {
+
+    public static final String IDENTIFIER = "remove_orphan_blobs";
+    private static final String OLDER_THAN = "older_than";
+    private static final String DRY_RUN = "dry_run";
+    private static final String PARALLELISM = "parallelism";
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public Optional<Action> create(MultipleParameterToolAdapter params) {
+        boolean dryRun = false;
+        if (params.has(DRY_RUN)) {
+            String dryRunValue = params.get(DRY_RUN);
+            checkArgument(
+                    "true".equalsIgnoreCase(dryRunValue) || 
"false".equalsIgnoreCase(dryRunValue),
+                    "Argument 'dry_run' must be either 'true' or 'false', but 
was '%s'.",
+                    dryRunValue);
+            dryRun = Boolean.parseBoolean(dryRunValue);
+        }
+
+        RemoveOrphanBlobsAction action =
+                new RemoveOrphanBlobsAction(
+                        params.getRequired(DATABASE),
+                        params.get(TABLE),
+                        params.get(PARALLELISM),
+                        catalogConfigMap(params));
+
+        if (params.has(OLDER_THAN)) {
+            action.olderThan(params.get(OLDER_THAN));
+        }
+
+        if (dryRun) {
+            action.dryRun();
+        }
+
+        return Optional.of(action);
+    }
+
+    @Override
+    public void printHelp() {
+        System.out.println(
+                "Action \"remove_orphan_blobs\" removes unreferenced 
primary-key managed BLOB packs.");
+        System.out.println();
+        System.out.println("Syntax:");
+        System.out.println(
+                "  remove_orphan_blobs \\\n"
+                        + "--warehouse <warehouse_path> \\\n"
+                        + "--database <database_name> \\\n"
+                        + "--table <table_name> \\\n"
+                        + "[--older_than <timestamp>] \\\n"
+                        + "[--dry_run <false/true>] \\\n"
+                        + "[--parallelism <positive_integer>]");
+        System.out.println();
+        System.out.println(
+                "To avoid deleting newly written packs, the default cutoff is 
1 day before the action starts. "
+                        + "'--older_than' sets the absolute cutoff timestamp; 
only packs with an earlier modification time are eligible. "
+                        + "<timestamp> format: yyyy-MM-dd HH:mm:ss");
+        System.out.println();
+        System.out.println(
+                "When '--dry_run true', calculate the orphan pack count and 
total bytes without deleting files. Default is false.");
+        System.out.println();
+        System.out.println(
+                "'--parallelism' controls the parallelism of each table 
cleanup job. It must be greater than 0.");
+        System.out.println();
+        System.out.println(
+                "If the table is null or *, all managed BLOB packs in all 
tables under the db will be cleaned up.");
+        System.out.println();
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkManagedBlobOrphanFilesClean.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkManagedBlobOrphanFilesClean.java
new file mode 100644
index 0000000000..e5498176b3
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkManagedBlobOrphanFilesClean.java
@@ -0,0 +1,965 @@
+/*
+ * 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.orphan;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.utils.BoundedOneInputOperator;
+import org.apache.paimon.flink.utils.BoundedTwoInputOperator;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.ManifestFile;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.manifest.ManifestList;
+import org.apache.paimon.operation.CleanOrphanFilesResult;
+import org.apache.paimon.operation.ManagedBlobOrphanFilesClean;
+import org.apache.paimon.operation.ManagedBlobOrphanFilesClean.SidecarWorkItem;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.utils.DataFilePathFactories;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import org.apache.flink.api.common.BatchShuffleMode;
+import org.apache.flink.api.common.RuntimeExecutionMode;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.functions.OpenContext;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeinfo.Types;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.api.java.tuple.Tuple3;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.CoreOptions;
+import org.apache.flink.configuration.ExecutionOptions;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.functions.ProcessFunction;
+import org.apache.flink.streaming.api.functions.sink.v2.DiscardingSink;
+import org.apache.flink.streaming.api.operators.InputSelection;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.util.CloseableIterator;
+import org.apache.flink.util.Collector;
+import org.apache.flink.util.OutputTag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import static 
org.apache.flink.api.common.typeinfo.BasicTypeInfo.STRING_TYPE_INFO;
+import static org.apache.flink.util.Preconditions.checkState;
+import static org.apache.paimon.utils.FileStorePathFactory.BUCKET_PATH_PREFIX;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Flink {@link ManagedBlobOrphanFilesClean}. */
+public class FlinkManagedBlobOrphanFilesClean extends 
ManagedBlobOrphanFilesClean {
+
+    private static final Logger LOG =
+            LoggerFactory.getLogger(FlinkManagedBlobOrphanFilesClean.class);
+
+    @Nullable private final Integer parallelism;
+
+    public FlinkManagedBlobOrphanFilesClean(
+            FileStoreTable table,
+            long olderThanMillis,
+            boolean dryRun,
+            @Nullable Integer parallelism) {
+        super(table, olderThanMillis, dryRun);
+        validateParallelism(parallelism);
+        this.parallelism = parallelism;
+    }
+
+    @Nullable
+    public DataStream<CleanOrphanFilesResult> 
doClean(StreamExecutionEnvironment env) {
+        configureEnv(env);
+        FrozenUsedPacks frozen = freezeUsedPacks(env);
+        if (frozen.abort) {
+            return env.fromCollection(
+                    Collections.singletonList(new CleanOrphanFilesResult(0, 
0)),
+                    TypeInformation.of(CleanOrphanFilesResult.class));
+        }
+        return deleteUnused(env, frozen.used);
+    }
+
+    private void configureEnv(StreamExecutionEnvironment env) {
+        Configuration flinkConf = new Configuration();
+        flinkConf.set(ExecutionOptions.RUNTIME_MODE, 
RuntimeExecutionMode.BATCH);
+        flinkConf.set(ExecutionOptions.SORT_INPUTS, false);
+        flinkConf.set(ExecutionOptions.USE_BATCH_STATE_BACKEND, false);
+        // Bounded two-input operators fully consume the first input before 
the second. A pipelined
+        // shuffle can deadlock that build/probe order, so force blocking 
exchanges.
+        flinkConf.set(ExecutionOptions.BATCH_SHUFFLE_MODE, 
BatchShuffleMode.ALL_EXCHANGES_BLOCKING);
+        if (parallelism != null) {
+            flinkConf.set(CoreOptions.DEFAULT_PARALLELISM, parallelism);
+        }
+        
flinkConf.setString("execution.batch.adaptive.auto-parallelism.enabled", 
"false");
+        env.configure(flinkConf);
+    }
+
+    private FrozenUsedPacks freezeUsedPacks(StreamExecutionEnvironment env) {
+        List<String> topologyBefore;
+        try {
+            topologyBefore = snapshotTopology();
+        } catch (java.io.IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        List<String> branches = validBranches();
+        final OutputTag<Boolean> firstMarkSkipGcTag =
+                new OutputTag<Boolean>("first-managed-blob-mark-skip") {};
+        SingleOutputStreamOperator<Tuple2<String, String>> firstManifestLists =
+                env.fromCollection(branches)
+                        .name("branch-source")
+                        .process(
+                                new ProcessFunction<String, Tuple2<String, 
String>>() {
+                                    @Override
+                                    public void processElement(
+                                            String branch,
+                                            ProcessFunction<String, 
Tuple2<String, String>>.Context
+                                                    ctx,
+                                            Collector<Tuple2<String, String>> 
out)
+                                            throws Exception {
+                                        emitManifestLists(branch, 
out::collect);
+                                    }
+                                })
+                        .name("collect-first-mark-manifest-lists");
+
+        SingleOutputStreamOperator<String> usedPacks =
+                collectUsedPacks(firstManifestLists, firstMarkSkipGcTag, 
"first");
+
+        DataStream<Boolean> firstMarkCompleted = markCompletion(usedPacks, 
"first");
+        SingleOutputStreamOperator<Tuple2<String, String>> secondManifestLists 
=
+                firstMarkCompleted
+                        .transform(
+                                "wait-before-second-managed-blob-mark",
+                                Types.TUPLE(Types.STRING, Types.STRING),
+                                new BoundedOneInputOperator<Boolean, 
Tuple2<String, String>>() {
+
+                                    @Override
+                                    public void 
processElement(StreamRecord<Boolean> element) {}
+
+                                    @Override
+                                    public void endInput() throws Exception {
+                                        for (String branch : branches) {
+                                            emitManifestLists(
+                                                    branch,
+                                                    manifestList ->
+                                                            output.collect(
+                                                                    new 
StreamRecord<>(
+                                                                            
manifestList)));
+                                        }
+                                    }
+                                })
+                        .forceNonParallel();
+
+        final OutputTag<Boolean> secondMarkSkipGcTag =
+                new OutputTag<Boolean>("second-managed-blob-mark-skip") {};
+        SingleOutputStreamOperator<String> usedPacks2 =
+                collectUsedPacks(secondManifestLists, secondMarkSkipGcTag, 
"second");
+
+        DataStream<Boolean> usedPacksChanged = compareUsedPacks(usedPacks, 
usedPacks2);
+        DataStream<Boolean> topologyChanged =
+                markCompletion(usedPacks2, "second")
+                        .transform(
+                                "check-managed-blob-snapshot-topology",
+                                TypeInformation.of(Boolean.class),
+                                new BoundedOneInputOperator<Boolean, 
Boolean>() {
+
+                                    @Override
+                                    public void 
processElement(StreamRecord<Boolean> element) {}
+
+                                    @Override
+                                    public void endInput() throws Exception {
+                                        List<String> topologyAfter = 
snapshotTopology();
+                                        if 
(!topologyBefore.equals(topologyAfter)) {
+                                            LOG.warn(
+                                                    "Skip managed blob pack GC 
for table {} because snapshot topology changed during used-pack collection.",
+                                                    table.fullName());
+                                            output.collect(new 
StreamRecord<>(Boolean.TRUE));
+                                        }
+                                    }
+                                })
+                        .forceNonParallel();
+
+        DataStream<Boolean> skipGc =
+                usedPacks
+                        .getSideOutput(firstMarkSkipGcTag)
+                        .union(
+                                usedPacks2.getSideOutput(secondMarkSkipGcTag),
+                                usedPacksChanged,
+                                topologyChanged);
+
+        // executeAndCollect materializes abort flags and used names together. 
Deletion re-sources
+        // the frozen names and must not reconnect the mark DAG: a lost 
used-pack shuffle with a
+        // surviving sidecar shuffle can drop a reused pack and still agree. 
Cost: client memory
+        // grows with the used-pack set. An OOM fails the job instead of 
deleting live packs.
+        DataStream<String> freezeRows =
+                usedPacks2.union(
+                        skipGc.map(
+                                        new MapFunction<Boolean, String>() {
+                                            @Override
+                                            public String map(Boolean value) {
+                                                return SKIP_MANAGED_BLOB_GC;
+                                            }
+                                        })
+                                .returns(STRING_TYPE_INFO));
+
+        HashSet<String> used = new HashSet<>();
+        boolean abort = false;
+        try (CloseableIterator<String> iterator =
+                freezeRows.executeAndCollect("FreezeManagedBlobUsedPacks")) {
+            while (iterator.hasNext()) {
+                String name = iterator.next();
+                if (SKIP_MANAGED_BLOB_GC.equals(name)) {
+                    abort = true;
+                } else {
+                    used.add(name);
+                }
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+
+        if (abort) {
+            LOG.warn(
+                    "Skip managed blob pack GC for table {} because sidecars, 
manifests, or snapshot topology cannot be trusted, or the used pack set changed 
during collection.",
+                    table.fullName());
+        } else {
+            LOG.info(
+                    "Frozen {} used managed blob pack names for table {} 
before deletion.",
+                    used.size(),
+                    table.fullName());
+        }
+        return new FrozenUsedPacks(abort, used);
+    }
+
+    private DataStream<CleanOrphanFilesResult> deleteUnused(
+            StreamExecutionEnvironment env, Set<String> frozenUsed) {
+        configureEnv(env);
+        final OutputTag<Boolean> candidateSkipGcTag =
+                new OutputTag<Boolean>("candidate-managed-blob-skip") {};
+        SingleOutputStreamOperator<Tuple3<String, String, Long>> candidates =
+                env.fromCollection(Collections.singletonList(1), 
TypeInformation.of(Integer.class))
+                        .process(
+                                new ProcessFunction<Integer, String>() {
+                                    @Override
+                                    public void processElement(
+                                            Integer i,
+                                            ProcessFunction<Integer, 
String>.Context ctx,
+                                            Collector<String> out) {
+                                        FileStorePathFactory pathFactory =
+                                                table.store().pathFactory();
+                                        listPaimonFileDirs(
+                                                        table.fullName(),
+                                                        
pathFactory.manifestPath().toString(),
+                                                        
pathFactory.indexPath().toString(),
+                                                        
pathFactory.statisticsPath().toString(),
+                                                        
pathFactory.dataFilePath().toString(),
+                                                        partitionKeysNum,
+                                                        
table.coreOptions().dataFileExternalPaths())
+                                                .stream()
+                                                .map(Path::toUri)
+                                                .map(Object::toString)
+                                                .forEach(out::collect);
+                                    }
+                                })
+                        .name("list-dirs")
+                        .forceNonParallel()
+                        .process(
+                                new ProcessFunction<String, Tuple3<String, 
String, Long>>() {
+                                    @Override
+                                    public void processElement(
+                                            String dir,
+                                            ProcessFunction<String, 
Tuple3<String, String, Long>>
+                                                            .Context
+                                                    ctx,
+                                            Collector<Tuple3<String, String, 
Long>> out) {
+                                        for (FileStatus file : 
tryBestListingDirs(new Path(dir))) {
+                                            if (!file.isDir()
+                                                    && oldEnough(file)
+                                                    && isManagedBlobPackName(
+                                                            
file.getPath().getName())) {
+                                                Optional<String> identity =
+                                                        
FlinkManagedBlobOrphanFilesClean.this
+                                                                
.packIdentityForCleanup(
+                                                                        
file.getPath());
+                                                if (identity.isPresent()) {
+                                                    out.collect(
+                                                            Tuple3.of(
+                                                                    
identity.get(),
+                                                                    
file.getPath().toString(),
+                                                                    
file.getLen()));
+                                                } else {
+                                                    LOG.warn(
+                                                            "Cannot safely 
identify candidate managed blob pack {}. Skip pack GC this run.",
+                                                            file.getPath());
+                                                    
ctx.output(candidateSkipGcTag, Boolean.TRUE);
+                                                }
+                                            }
+                                        }
+                                    }
+                                })
+                        .name("collect-candidate-packs");
+
+        DataStream<Tuple3<String, String, Long>> unused =
+                unusedCandidates(env, frozenUsed, candidates);
+
+        DataStream<Boolean> skipGc = 
candidates.getSideOutput(candidateSkipGcTag);
+
+        final OutputTag<Path> emptyDirTag = new 
OutputTag<Path>("empty-managed-blob-dir") {};
+        SingleOutputStreamOperator<CleanOrphanFilesResult> cleaned =
+                unused.keyBy(candidate -> candidate.f0)
+                        .connect(skipGc.broadcast())
+                        .transform(
+                                "clean-unused-managed-blobs",
+                                
TypeInformation.of(CleanOrphanFilesResult.class),
+                                new BoundedTwoInputOperator<
+                                        Tuple3<String, String, Long>,
+                                        Boolean,
+                                        CleanOrphanFilesResult>() {
+
+                                    private boolean skipEnded;
+                                    private boolean skipGc;
+                                    private long emittedFilesCount;
+                                    private long emittedFilesLen;
+                                    private final Set<String> 
completedCandidates = new HashSet<>();
+
+                                    @Override
+                                    public InputSelection nextSelection() {
+                                        return skipEnded
+                                                ? InputSelection.FIRST
+                                                : InputSelection.SECOND;
+                                    }
+
+                                    @Override
+                                    public void endInput(int inputId) {
+                                        switch (inputId) {
+                                            case 2:
+                                                checkState(!skipEnded, "Should 
not skip ended.");
+                                                skipEnded = true;
+                                                LOG.info("Managed blob GC skip 
flag: {}", skipGc);
+                                                break;
+                                            case 1:
+                                                checkState(skipEnded, "Should 
skip ended.");
+                                                output.collect(
+                                                        new StreamRecord<>(
+                                                                new 
CleanOrphanFilesResult(
+                                                                        
emittedFilesCount,
+                                                                        
emittedFilesLen)));
+                                                break;
+                                        }
+                                    }
+
+                                    @Override
+                                    public void processElement1(
+                                            StreamRecord<Tuple3<String, 
String, Long>> element) {
+                                        checkState(skipEnded, "Should skip 
ended.");
+                                        if (skipGc) {
+                                            return;
+                                        }
+                                        Tuple3<String, String, Long> candidate 
= element.getValue();
+                                        if 
(completedCandidates.contains(candidate.f0)) {
+                                            return;
+                                        }
+                                        Path path = new Path(candidate.f1);
+                                        if (cleanPack(path)) {
+                                            
completedCandidates.add(candidate.f0);
+                                            emittedFilesCount++;
+                                            emittedFilesLen += candidate.f2;
+                                            Path parent = path.getParent();
+                                            if (parent != null
+                                                    && parent.toString()
+                                                            
.contains(BUCKET_PATH_PREFIX)) {
+                                                output.collect(
+                                                        emptyDirTag, new 
StreamRecord<>(parent));
+                                            }
+                                            LOG.info("Cleaned managed blob 
pack: {}", path);
+                                        }
+                                    }
+
+                                    @Override
+                                    public void 
processElement2(StreamRecord<Boolean> element) {
+                                        skipGc = true;
+                                    }
+                                });
+
+        cleaned.getSideOutput(emptyDirTag)
+                .transform(
+                        "clean-empty-dirs",
+                        STRING_TYPE_INFO,
+                        new BoundedOneInputOperator<Path, String>() {
+
+                            private final Set<Path> bucketDirs = new 
HashSet<>();
+
+                            @Override
+                            public void processElement(StreamRecord<Path> 
element) {
+                                bucketDirs.add(element.getValue());
+                            }
+
+                            @Override
+                            public void endInput() {
+                                tryCleanDataDirectory(bucketDirs, 
partitionKeysNum + 1);
+                            }
+                        })
+                .forceNonParallel()
+                .sinkTo(new DiscardingSink<>())
+                .name("end")
+                .setParallelism(1)
+                .setMaxParallelism(1);
+
+        return cleaned;
+    }
+
+    // Re-source frozen used identities so deletion does not reconnect the 
mark DAG. Keep the source
+    // serial so each worker does not deserialize the full used set; the keyed 
anti-join retains
+    // only its key partition.
+    private DataStream<Tuple3<String, String, Long>> unusedCandidates(
+            StreamExecutionEnvironment env,
+            Set<String> frozenUsed,
+            DataStream<Tuple3<String, String, Long>> candidates) {
+        if (frozenUsed.isEmpty()) {
+            return candidates;
+        }
+        DataStream<String> frozenNames =
+                env.fromCollection(new ArrayList<>(frozenUsed), 
STRING_TYPE_INFO)
+                        .name("frozen-used-managed-blob-packs")
+                        .setParallelism(1);
+        SingleOutputStreamOperator<Tuple3<String, String, Long>> unused =
+                frozenNames
+                        .keyBy(name -> name)
+                        .connect(candidates.keyBy(candidate -> candidate.f0))
+                        .transform(
+                                "join-used-and-candidate-packs",
+                                Types.TUPLE(Types.STRING, Types.STRING, 
Types.LONG),
+                                new UnusedCandidateJoinOperator());
+        if (parallelism != null) {
+            unused.setParallelism(parallelism);
+        }
+        return unused;
+    }
+
+    protected SingleOutputStreamOperator<String> collectUsedPacks(
+            DataStream<Tuple2<String, String>> manifestLists,
+            OutputTag<Boolean> skipGcTag,
+            String markName) {
+        SingleOutputStreamOperator<Tuple2<String, String>> 
distinctManifestLists =
+                deduplicateNamedFiles(manifestLists, markName + 
"-managed-blob-manifest-lists");
+
+        SingleOutputStreamOperator<Tuple2<String, String>> manifests =
+                distinctManifestLists
+                        .process(
+                                new ProcessFunction<
+                                        Tuple2<String, String>, Tuple2<String, 
String>>() {
+
+                                    private transient Map<String, 
ManifestList> readers;
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(OpenContext openContext) {
+                                        open(new Configuration());
+                                    }
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(Configuration parameters) 
{
+                                        readers = new HashMap<>();
+                                    }
+
+                                    @Override
+                                    public void processElement(
+                                            Tuple2<String, String> 
branchAndList,
+                                            ProcessFunction<
+                                                                    
Tuple2<String, String>,
+                                                                    
Tuple2<String, String>>
+                                                            .Context
+                                                    ctx,
+                                            Collector<Tuple2<String, String>> 
out)
+                                            throws Exception {
+                                        String branch = branchAndList.f0;
+                                        ManifestList reader = 
readers.get(branch);
+                                        if (reader == null) {
+                                            reader =
+                                                    
table.switchToBranch(branch)
+                                                            .store()
+                                                            
.manifestListFactory()
+                                                            .create();
+                                            readers.put(branch, reader);
+                                        }
+                                        final ManifestList manifestListReader 
= reader;
+                                        List<ManifestFileMeta> listed =
+                                                retryReadingFiles(
+                                                        () ->
+                                                                
manifestListReader
+                                                                        
.readWithIOException(
+                                                                               
 branchAndList.f1),
+                                                        null);
+                                        if (listed == null) {
+                                            LOG.warn(
+                                                    "Manifest list {} is 
missing while collecting used managed blob packs. Skip pack GC this run.",
+                                                    branchAndList.f1);
+                                            out.collect(Tuple2.of(branch, 
SKIP_MANAGED_BLOB_GC));
+                                            return;
+                                        }
+                                        for (ManifestFileMeta meta : listed) {
+                                            out.collect(Tuple2.of(branch, 
meta.fileName()));
+                                        }
+                                    }
+                                })
+                        .name("read-" + markName + 
"-managed-blob-manifest-lists");
+        if (parallelism != null) {
+            manifests.setParallelism(parallelism);
+        }
+
+        SingleOutputStreamOperator<Tuple2<String, String>> distinctManifests =
+                deduplicateNamedFiles(manifests, markName + 
"-managed-blob-manifests");
+        final OutputTag<String> manifestSkipGcTag =
+                new OutputTag<String>(markName + 
"-managed-blob-manifest-skip") {};
+        SingleOutputStreamOperator<SidecarWorkItem> sidecars =
+                distinctManifests
+                        .process(
+                                new ProcessFunction<Tuple2<String, String>, 
SidecarWorkItem>() {
+
+                                    private transient Map<String, 
ManifestFile> manifestFiles;
+                                    private transient Map<String, 
DataFilePathFactories>
+                                            pathFactories;
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(OpenContext openContext) {
+                                        open(new Configuration());
+                                    }
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(Configuration parameters) 
{
+                                        manifestFiles = new HashMap<>();
+                                        pathFactories = new HashMap<>();
+                                    }
+
+                                    @Override
+                                    public void processElement(
+                                            Tuple2<String, String> 
branchAndManifest,
+                                            ProcessFunction<Tuple2<String, 
String>, SidecarWorkItem>
+                                                            .Context
+                                                    ctx,
+                                            Collector<SidecarWorkItem> out)
+                                            throws Exception {
+                                        if 
(SKIP_MANAGED_BLOB_GC.equals(branchAndManifest.f1)) {
+                                            ctx.output(manifestSkipGcTag, 
SKIP_MANAGED_BLOB_GC);
+                                            return;
+                                        }
+
+                                        String branch = branchAndManifest.f0;
+                                        ManifestFile manifestFile = 
manifestFiles.get(branch);
+                                        DataFilePathFactories factories = 
pathFactories.get(branch);
+                                        if (manifestFile == null) {
+                                            FileStoreTable branchTable =
+                                                    
table.switchToBranch(branch);
+                                            manifestFile =
+                                                    branchTable
+                                                            .store()
+                                                            
.manifestFileFactory()
+                                                            .create();
+                                            factories =
+                                                    new DataFilePathFactories(
+                                                            
branchTable.store().pathFactory());
+                                            manifestFiles.put(branch, 
manifestFile);
+                                            pathFactories.put(branch, 
factories);
+                                        }
+
+                                        final ManifestFile manifestReader = 
manifestFile;
+                                        List<ManifestEntry> entries =
+                                                retryReadingFiles(
+                                                        () ->
+                                                                
manifestReader.readWithIOException(
+                                                                        
branchAndManifest.f1),
+                                                        null);
+                                        if (entries == null) {
+                                            LOG.warn(
+                                                    "Manifest {} is missing 
while collecting used managed blob packs. Skip pack GC this run.",
+                                                    branchAndManifest.f1);
+                                            ctx.output(manifestSkipGcTag, 
SKIP_MANAGED_BLOB_GC);
+                                            return;
+                                        }
+                                        for (ManifestEntry entry : entries) {
+                                            for (SidecarWorkItem workItem :
+                                                    
FlinkManagedBlobOrphanFilesClean.this
+                                                            
.createSidecarWorkItems(
+                                                                    entry,
+                                                                    
factories.get(
+                                                                            
entry.partition(),
+                                                                            
entry.bucket()))) {
+                                                out.collect(workItem);
+                                            }
+                                        }
+                                    }
+                                })
+                        .name("collect-" + markName + "-managed-blob-sidecars")
+                        .returns(TypeInformation.of(SidecarWorkItem.class));
+        if (parallelism != null) {
+            sidecars.setParallelism(parallelism);
+        }
+
+        SingleOutputStreamOperator<SidecarWorkItem> distinctSidecars =
+                sidecars.keyBy(SidecarWorkItem::dedupIdentity)
+                        .transform(
+                                "deduplicate-" + markName + 
"-managed-blob-sidecars",
+                                TypeInformation.of(SidecarWorkItem.class),
+                                new BoundedOneInputOperator<SidecarWorkItem, 
SidecarWorkItem>() {
+
+                                    private final Set<String> identities = new 
HashSet<>();
+
+                                    @Override
+                                    public void processElement(
+                                            StreamRecord<SidecarWorkItem> 
element) {
+                                        if 
(identities.add(element.getValue().dedupIdentity())) {
+                                            output.collect(element);
+                                        }
+                                    }
+
+                                    @Override
+                                    public void endInput() {}
+                                });
+        if (parallelism != null) {
+            distinctSidecars.setParallelism(parallelism);
+        }
+
+        SingleOutputStreamOperator<String> marked =
+                distinctSidecars
+                        .process(
+                                new ProcessFunction<SidecarWorkItem, String>() 
{
+
+                                    private transient 
ManagedBlobOrphanFilesClean.ReachabilityScan
+                                            scan;
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(OpenContext openContext) {
+                                        open(new Configuration());
+                                    }
+
+                                    // @Override is skipped for compatibility 
between Flink versions
+                                    public void open(Configuration parameters) 
{
+                                        scan =
+                                                
FlinkManagedBlobOrphanFilesClean.this
+                                                        .newReachabilityScan();
+                                    }
+
+                                    @Override
+                                    public void processElement(
+                                            SidecarWorkItem workItem,
+                                            ProcessFunction<SidecarWorkItem, 
String>.Context ctx,
+                                            Collector<String> out) {
+                                        
FlinkManagedBlobOrphanFilesClean.this.emitUsedPacks(
+                                                workItem, scan, out::collect);
+                                    }
+                                })
+                        .name(markName + "-managed-blob-mark")
+                        .returns(STRING_TYPE_INFO);
+        if (parallelism != null) {
+            marked.setParallelism(parallelism);
+        }
+
+        DataStream<String> markedAndManifestSkips =
+                marked.union(sidecars.getSideOutput(manifestSkipGcTag));
+        return markedAndManifestSkips
+                .keyBy(identity -> identity)
+                .transform(
+                        "deduplicate-" + markName + "-managed-blob-mark",
+                        STRING_TYPE_INFO,
+                        new BoundedOneInputOperator<String, String>() {
+
+                            private final Set<String> identities = new 
HashSet<>();
+
+                            @Override
+                            public void processElement(StreamRecord<String> 
element) {
+                                identities.add(element.getValue());
+                            }
+
+                            @Override
+                            public void endInput() {
+                                for (String identity : identities) {
+                                    if (SKIP_MANAGED_BLOB_GC.equals(identity)) 
{
+                                        output.collect(skipGcTag, new 
StreamRecord<>(Boolean.TRUE));
+                                    } else {
+                                        output.collect(new 
StreamRecord<>(identity));
+                                    }
+                                }
+                            }
+                        });
+    }
+
+    private SingleOutputStreamOperator<Tuple2<String, String>> 
deduplicateNamedFiles(
+            DataStream<Tuple2<String, String>> files, String operatorName) {
+        SingleOutputStreamOperator<Tuple2<String, String>> deduplicated =
+                files.keyBy(file -> namedFileIdentity(file.f0, file.f1))
+                        .transform(
+                                "deduplicate-" + operatorName,
+                                Types.TUPLE(Types.STRING, Types.STRING),
+                                new BoundedOneInputOperator<
+                                        Tuple2<String, String>, Tuple2<String, 
String>>() {
+
+                                    private final Set<String> identities = new 
HashSet<>();
+
+                                    @Override
+                                    public void processElement(
+                                            StreamRecord<Tuple2<String, 
String>> element) {
+                                        Tuple2<String, String> file = 
element.getValue();
+                                        if 
(identities.add(namedFileIdentity(file.f0, file.f1))) {
+                                            output.collect(
+                                                    new StreamRecord<>(
+                                                            Tuple2.of(file.f0, 
file.f1)));
+                                        }
+                                    }
+
+                                    @Override
+                                    public void endInput() {}
+                                });
+        if (parallelism != null) {
+            deduplicated.setParallelism(parallelism);
+        }
+        return deduplicated;
+    }
+
+    private static String namedFileIdentity(String branch, String fileName) {
+        return branch + '\0' + fileName;
+    }
+
+    private void emitManifestLists(String branch, Consumer<Tuple2<String, 
String>> manifestLists)
+            throws Exception {
+        for (Snapshot snapshot : safelyGetAllSnapshots(branch)) {
+            emitManifestList(branch, snapshot.changelogManifestList(), 
manifestLists);
+            emitManifestList(branch, snapshot.deltaManifestList(), 
manifestLists);
+            emitManifestList(branch, snapshot.baseManifestList(), 
manifestLists);
+        }
+    }
+
+    private static void emitManifestList(
+            String branch,
+            @Nullable String manifestList,
+            Consumer<Tuple2<String, String>> manifestLists) {
+        if (manifestList != null) {
+            manifestLists.accept(Tuple2.of(branch, manifestList));
+        }
+    }
+
+    private DataStream<Boolean> markCompletion(DataStream<String> usedPacks, 
String markName) {
+        return usedPacks.transform(
+                markName + "-managed-blob-mark-completion",
+                TypeInformation.of(Boolean.class),
+                new BoundedOneInputOperator<String, Boolean>() {
+                    @Override
+                    public void processElement(StreamRecord<String> element) {}
+
+                    @Override
+                    public void endInput() {
+                        output.collect(new StreamRecord<>(Boolean.TRUE));
+                    }
+                });
+    }
+
+    private DataStream<Boolean> compareUsedPacks(
+            DataStream<String> usedPacks, DataStream<String> usedPacks2) {
+        return usedPacks
+                .keyBy(identity -> identity)
+                .connect(usedPacks2.keyBy(identity -> identity))
+                .transform(
+                        "compare-managed-blob-marks",
+                        TypeInformation.of(Boolean.class),
+                        new BoundedTwoInputOperator<String, String, Boolean>() 
{
+
+                            private boolean firstMarkEnded;
+                            private boolean marksDiffer;
+                            private final Set<String> firstMark = new 
HashSet<>();
+
+                            @Override
+                            public InputSelection nextSelection() {
+                                return firstMarkEnded
+                                        ? InputSelection.SECOND
+                                        : InputSelection.FIRST;
+                            }
+
+                            @Override
+                            public void endInput(int inputId) {
+                                switch (inputId) {
+                                    case 1:
+                                        checkState(
+                                                !firstMarkEnded,
+                                                "Should not have finished the 
first mark.");
+                                        firstMarkEnded = true;
+                                        break;
+                                    case 2:
+                                        checkState(
+                                                firstMarkEnded,
+                                                "Should have finished the 
first mark.");
+                                        if (marksDiffer || 
!firstMark.isEmpty()) {
+                                            LOG.warn(
+                                                    "Skip managed blob pack GC 
for table {} because the used pack set changed during used-pack collection.",
+                                                    table.fullName());
+                                            output.collect(new 
StreamRecord<>(Boolean.TRUE));
+                                        }
+                                        break;
+                                }
+                            }
+
+                            @Override
+                            public void processElement1(StreamRecord<String> 
element) {
+                                firstMark.add(element.getValue());
+                            }
+
+                            @Override
+                            public void processElement2(StreamRecord<String> 
element) {
+                                checkState(firstMarkEnded, "Should have 
finished the first mark.");
+                                if (!firstMark.remove(element.getValue())) {
+                                    marksDiffer = true;
+                                }
+                            }
+                        });
+    }
+
+    protected boolean cleanPack(Path path) {
+        return cleanManagedBlobFileIdempotently(path);
+    }
+
+    public static CleanOrphanFilesResult executeDatabase(
+            StreamExecutionEnvironment env,
+            Catalog catalog,
+            long olderThanMillis,
+            boolean dryRun,
+            @Nullable Integer parallelism,
+            String databaseName,
+            @Nullable String tableName)
+            throws Catalog.DatabaseNotExistException, 
Catalog.TableNotExistException {
+        validateParallelism(parallelism);
+        List<String> tableNames = Collections.singletonList(tableName);
+        if (tableName == null || "*".equals(tableName)) {
+            tableNames = catalog.listTables(databaseName);
+        }
+
+        // Freeze+delete must finish for one table before the next table's 
operators are added to
+        // the same environment. Unioning table graphs would let a later 
freeze executeAndCollect
+        // also run an earlier table's uncommitted deletion DAG.
+        long deletedFilesCount = 0;
+        long deletedFilesLenInBytes = 0;
+        for (String t : tableNames) {
+            Identifier identifier = new Identifier(databaseName, t);
+            Table table = catalog.getTable(identifier);
+            checkArgument(
+                    table instanceof FileStoreTable,
+                    "Only FileStoreTable supports remove-orphan-blobs action. 
The table type is '%s'.",
+                    table.getClass().getName());
+            DataStream<CleanOrphanFilesResult> clean =
+                    new FlinkManagedBlobOrphanFilesClean(
+                                    (FileStoreTable) table, olderThanMillis, 
dryRun, parallelism)
+                            .doClean(env);
+            if (clean != null) {
+                CleanOrphanFilesResult one = sum(clean);
+                deletedFilesCount += one.getDeletedFileCount();
+                deletedFilesLenInBytes += one.getDeletedFileTotalLenInBytes();
+            }
+        }
+        return new CleanOrphanFilesResult(deletedFilesCount, 
deletedFilesLenInBytes);
+    }
+
+    private static CleanOrphanFilesResult 
sum(DataStream<CleanOrphanFilesResult> deleted) {
+        long deletedFilesCount = 0;
+        long deletedFilesLenInBytes = 0;
+        if (deleted != null) {
+            try (CloseableIterator<CleanOrphanFilesResult> iterator =
+                    
deleted.global().executeAndCollect("ManagedBlobOrphanFilesClean")) {
+                while (iterator.hasNext()) {
+                    CleanOrphanFilesResult cleanOrphanFilesResult = 
iterator.next();
+                    deletedFilesCount += 
cleanOrphanFilesResult.getDeletedFileCount();
+                    deletedFilesLenInBytes +=
+                            
cleanOrphanFilesResult.getDeletedFileTotalLenInBytes();
+                }
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+        return new CleanOrphanFilesResult(deletedFilesCount, 
deletedFilesLenInBytes);
+    }
+
+    private static final class FrozenUsedPacks {
+        private final boolean abort;
+        private final Set<String> used;
+
+        private FrozenUsedPacks(boolean abort, Set<String> used) {
+            this.abort = abort;
+            this.used = used;
+        }
+    }
+
+    // Keyed bounded anti-join: build used identities from the 
lineage-detached source, then emit
+    // candidates that are not in this task's key partition.
+    private static final class UnusedCandidateJoinOperator
+            extends BoundedTwoInputOperator<
+                    String, Tuple3<String, String, Long>, Tuple3<String, 
String, Long>> {
+
+        private boolean buildEnd;
+        private final Set<String> used = new HashSet<>();
+
+        @Override
+        public InputSelection nextSelection() {
+            return buildEnd ? InputSelection.SECOND : InputSelection.FIRST;
+        }
+
+        @Override
+        public void endInput(int inputId) {
+            switch (inputId) {
+                case 1:
+                    checkState(!buildEnd, "Should not build ended.");
+                    LOG.info("Finish build phase for frozen used managed blob 
packs.");
+                    buildEnd = true;
+                    break;
+                case 2:
+                    checkState(buildEnd, "Should build ended.");
+                    LOG.info("Finish probe phase for managed blob 
candidates.");
+                    break;
+            }
+        }
+
+        @Override
+        public void processElement1(StreamRecord<String> element) {
+            used.add(element.getValue());
+        }
+
+        @Override
+        public void processElement2(StreamRecord<Tuple3<String, String, Long>> 
element) {
+            checkState(buildEnd, "Should build ended.");
+            Tuple3<String, String, Long> candidate = element.getValue();
+            if (!used.contains(candidate.f0)) {
+                output.collect(element);
+            }
+        }
+    }
+
+    public static void validateParallelism(@Nullable Integer parallelism) {
+        checkArgument(
+                parallelism == null || parallelism > 0,
+                "Parallelism must be greater than 0, but was %s.",
+                parallelism);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkOrphanFilesClean.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkOrphanFilesClean.java
index 3ce2bf82f8..1337016c88 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkOrphanFilesClean.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/orphan/FlinkOrphanFilesClean.java
@@ -33,6 +33,7 @@ import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.utils.FileStorePathFactory;
 
+import org.apache.flink.api.common.BatchShuffleMode;
 import org.apache.flink.api.common.RuntimeExecutionMode;
 import org.apache.flink.api.common.functions.ReduceFunction;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
@@ -92,6 +93,9 @@ public class FlinkOrphanFilesClean extends OrphanFilesClean {
         flinkConf.set(ExecutionOptions.RUNTIME_MODE, 
RuntimeExecutionMode.BATCH);
         flinkConf.set(ExecutionOptions.SORT_INPUTS, false);
         flinkConf.set(ExecutionOptions.USE_BATCH_STATE_BACKEND, false);
+        // The used/candidate join below fully consumes its first input before 
reading the second.
+        // A pipelined shuffle can deadlock that build/probe order, so force 
blocking exchanges.
+        flinkConf.set(ExecutionOptions.BATCH_SHUFFLE_MODE, 
BatchShuffleMode.ALL_EXCHANGES_BLOCKING);
         if (parallelism != null) {
             flinkConf.set(CoreOptions.DEFAULT_PARALLELISM, parallelism);
         }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
new file mode 100644
index 0000000000..09d0e16586
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RemoveOrphanBlobsProcedure.java
@@ -0,0 +1,120 @@
+/*
+ * 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.procedure;
+
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean;
+import org.apache.paimon.operation.CleanOrphanFilesResult;
+import org.apache.paimon.operation.LocalManagedBlobOrphanFilesClean;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+
+import java.util.Locale;
+
+import static 
org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean.validateParallelism;
+import static org.apache.paimon.operation.OrphanFilesClean.olderThanMillis;
+
+/**
+ * Remove orphan managed BLOB packs procedure. Usage:
+ *
+ * <pre><code>
+ *  CALL sys.remove_orphan_blobs('tableId')
+ *
+ *  CALL sys.remove_orphan_blobs('tableId', '2023-12-31 23:59:59')
+ *
+ *  CALL sys.remove_orphan_blobs('databaseName.*', '2023-12-31 23:59:59')
+ * </code></pre>
+ */
+public class RemoveOrphanBlobsProcedure extends ProcedureBase {
+
+    public static final String IDENTIFIER = "remove_orphan_blobs";
+
+    @ProcedureHint(
+            argument = {
+                @ArgumentHint(name = "table", type = @DataTypeHint("STRING")),
+                @ArgumentHint(
+                        name = "older_than",
+                        type = @DataTypeHint("STRING"),
+                        isOptional = true),
+                @ArgumentHint(name = "dry_run", type = 
@DataTypeHint("BOOLEAN"), isOptional = true),
+                @ArgumentHint(name = "parallelism", type = 
@DataTypeHint("INT"), isOptional = true),
+                @ArgumentHint(name = "mode", type = @DataTypeHint("STRING"), 
isOptional = true)
+            })
+    public String[] call(
+            ProcedureContext procedureContext,
+            String tableId,
+            String olderThan,
+            Boolean dryRun,
+            Integer parallelism,
+            String mode)
+            throws Exception {
+        validateParallelism(parallelism);
+        Identifier identifier = Identifier.fromString(tableId);
+        String databaseName = identifier.getDatabaseName();
+        String tableName = identifier.getObjectName();
+        if (mode == null) {
+            mode = "DISTRIBUTED";
+        }
+        CleanOrphanFilesResult result;
+        try {
+            switch (mode.toUpperCase(Locale.ROOT)) {
+                case "DISTRIBUTED":
+                    result =
+                            FlinkManagedBlobOrphanFilesClean.executeDatabase(
+                                    procedureContext.getExecutionEnvironment(),
+                                    catalog,
+                                    olderThanMillis(olderThan),
+                                    dryRun != null && dryRun,
+                                    parallelism,
+                                    databaseName,
+                                    tableName);
+                    break;
+                case "LOCAL":
+                    result =
+                            LocalManagedBlobOrphanFilesClean.executeDatabase(
+                                    catalog,
+                                    databaseName,
+                                    tableName,
+                                    olderThanMillis(olderThan),
+                                    parallelism,
+                                    dryRun != null && dryRun);
+                    break;
+                default:
+                    throw new IllegalArgumentException(
+                            "Unknown mode: "
+                                    + mode
+                                    + ". Only 'DISTRIBUTED' and 'LOCAL' are 
supported.");
+            }
+            return new String[] {
+                String.valueOf(result.getDeletedFileCount()),
+                String.valueOf(result.getDeletedFileTotalLenInBytes())
+            };
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
 
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
index 188b50550e..288431c31a 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
+++ 
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -33,6 +33,7 @@ org.apache.paimon.flink.action.ResetConsumerActionFactory
 org.apache.paimon.flink.action.MigrateTableActionFactory
 org.apache.paimon.flink.action.MigrateDatabaseActionFactory
 org.apache.paimon.flink.action.RemoveOrphanFilesActionFactory
+org.apache.paimon.flink.action.RemoveOrphanBlobsActionFactory
 org.apache.paimon.flink.action.QueryServiceActionFactory
 org.apache.paimon.flink.action.ExpirePartitionsActionFactory
 org.apache.paimon.flink.action.MarkPartitionDoneActionFactory
@@ -78,6 +79,7 @@ org.apache.paimon.flink.procedure.RollbackToWatermarkProcedure
 org.apache.paimon.flink.procedure.MigrateTableProcedure
 org.apache.paimon.flink.procedure.MigrateDatabaseProcedure
 org.apache.paimon.flink.procedure.RemoveOrphanFilesProcedure
+org.apache.paimon.flink.procedure.RemoveOrphanBlobsProcedure
 org.apache.paimon.flink.procedure.QueryServiceProcedure
 org.apache.paimon.flink.procedure.ExpireSnapshotsProcedure
 org.apache.paimon.flink.procedure.ExpireChangelogsProcedure
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ActionJobCoverageTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ActionJobCoverageTest.java
index 4e447e3972..f5c77c54e0 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ActionJobCoverageTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ActionJobCoverageTest.java
@@ -120,7 +120,10 @@ public class ActionJobCoverageTest {
                                 "<init>"),
                         Tuple2.of(
                                 
"org/apache/paimon/flink/orphan/FlinkOrphanFilesClean",
-                                "executeDatabaseOrphanFiles"));
+                                "executeDatabaseOrphanFiles"),
+                        Tuple2.of(
+                                
"org/apache/paimon/flink/orphan/FlinkManagedBlobOrphanFilesClean",
+                                "executeDatabase"));
 
         private static final List<Tuple2<String, String>> 
VALID_OWNER_PATTERN_AND_NAMES =
                 Collections.singletonList(
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCase.java
new file mode 100644
index 0000000000..196ffea58e
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCase.java
@@ -0,0 +1,22 @@
+/*
+ * 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.action;
+
+/** IT cases for {@link RemoveOrphanBlobsAction} in Flink Common. */
+public class RemoveOrphanBlobsActionITCase extends 
RemoveOrphanBlobsActionITCaseBase {}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCaseBase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCaseBase.java
new file mode 100644
index 0000000000..7c54c31070
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanBlobsActionITCaseBase.java
@@ -0,0 +1,935 @@
+/*
+ * 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.action;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.blob.ManagedBlobReferenceFile;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.flink.orphan.FlinkManagedBlobOrphanFilesClean;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.ManifestFile;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.manifest.ManifestList;
+import org.apache.paimon.operation.CleanOrphanFilesResult;
+import org.apache.paimon.operation.ManagedBlobOrphanFilesClean;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.sink.StreamWriteBuilder;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.DataFilePathFactories;
+import org.apache.paimon.utils.DateTimeUtils;
+import org.apache.paimon.utils.TraceableFileIO;
+
+import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList;
+
+import org.apache.flink.api.common.BatchShuffleMode;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.configuration.ExecutionOptions;
+import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.functions.ProcessFunction;
+import org.apache.flink.streaming.api.functions.sink.v2.DiscardingSink;
+import org.apache.flink.types.Row;
+import org.apache.flink.util.CloseableIterator;
+import org.apache.flink.util.Collector;
+import org.apache.flink.util.OutputTag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.paimon.catalog.Identifier.DEFAULT_MAIN_BRANCH;
+import static 
org.apache.paimon.testutils.assertj.PaimonAssertions.anyCauseMatches;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** IT cases for {@link RemoveOrphanBlobsAction}. */
+public abstract class RemoveOrphanBlobsActionITCaseBase extends 
ActionITCaseBase {
+
+    private static final AtomicBoolean FAIL_AFTER_FIRST_DELETE = new 
AtomicBoolean();
+    private static final List<CleanOrphanFilesResult> FAILOVER_CLEANUP_RESULTS 
=
+            Collections.synchronizedList(new 
ArrayList<CleanOrphanFilesResult>());
+    private static final AtomicInteger MARK_PASS = new AtomicInteger();
+    private static final List<String> FROZEN_USED_NAMES =
+            Collections.synchronizedList(new ArrayList<String>());
+
+    @ParameterizedTest
+    @ValueSource(strings = {"local", "distributed"})
+    public void testDeleteUnreferencedManagedBlobPack(String mode) throws 
Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        Path orphan = new Path(bucketPath(table), "orphan.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        List<Path> referenced =
+                filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        referenced.removeIf(p -> orphan.getName().equals(p.getName()));
+        assertThat(referenced).isNotEmpty();
+
+        ImmutableList.copyOf(executeSQL(removeOrphanBlobsCall(mode)));
+
+        assertThat(table.fileIO().exists(orphan)).isFalse();
+        for (Path pack : referenced) {
+            assertThat(table.fileIO().exists(pack)).isTrue();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"local", "distributed"})
+    public void testMissingManagedBlobSidecarSkipsPackGc(String mode) throws 
Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        Path orphanPack = new Path(bucketPath(table), "orphan.managed.blob");
+        Path orphanOther = new Path(bucketPath(table), "orphan.txt");
+        table.fileIO().newOutputStream(orphanPack, false).close();
+        table.fileIO().writeFile(orphanOther, "x", true);
+        Thread.sleep(2000);
+
+        List<Path> referenced =
+                filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        referenced.removeIf(p -> orphanPack.getName().equals(p.getName()));
+        assertThat(referenced).isNotEmpty();
+        deleteFilesWithSuffix(table, 
ManagedBlobReferenceFile.REFERENCE_FILE_SUFFIX);
+
+        ImmutableList.copyOf(executeSQL(removeOrphanBlobsCall(mode)));
+
+        assertThat(table.fileIO().exists(orphanPack)).isTrue();
+        assertThat(table.fileIO().exists(orphanOther)).isTrue();
+        for (Path pack : referenced) {
+            assertThat(table.fileIO().exists(pack)).isTrue();
+        }
+    }
+
+    @Test
+    public void testDistributedDeleteWithEmptyUsedPackSet() throws Exception {
+        FileStoreTable table = createManagedBlobTable();
+        Path bucket = bucketPath(table);
+        table.fileIO().mkdirs(bucket);
+        Path orphan = new Path(bucket, "orphan.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        ImmutableList.copyOf(executeSQL(removeOrphanBlobsCall("distributed")));
+
+        assertThat(table.fileIO().exists(orphan)).isFalse();
+    }
+
+    @Test
+    public void testDistributedUnresolvableRelativeCandidateSkipsGc() throws 
Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        Path orphan = new Path(bucketPath(table), "orphan.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        UnresolvableRelativeListingFileIO.reset();
+        FileStoreTable relativeListingTable =
+                FileStoreTableFactory.create(
+                        new UnresolvableRelativeListingFileIO(), 
table.location(), table.schema());
+        StreamExecutionEnvironment env =
+                
streamExecutionEnvironmentBuilder().batchMode().parallelism(5).build();
+        FlinkManagedBlobOrphanFilesClean cleaner =
+                new FlinkManagedBlobOrphanFilesClean(
+                        relativeListingTable, Long.MAX_VALUE, false, 5);
+        List<CleanOrphanFilesResult> cleanResults = new ArrayList<>();
+        try (CloseableIterator<CleanOrphanFilesResult> results =
+                cleaner.doClean(env).executeAndCollect()) {
+            while (results.hasNext()) {
+                cleanResults.add(results.next());
+            }
+        }
+
+        
assertThat(UnresolvableRelativeListingFileIO.resolveAttempts()).isPositive();
+        assertThat(cleanResults).isNotEmpty();
+        assertThat(cleanResults)
+                .allSatisfy(
+                        result -> {
+                            assertThat(result.getDeletedFileCount()).isZero();
+                            
assertThat(result.getDeletedFileTotalLenInBytes()).isZero();
+                        });
+        assertThat(table.fileIO().exists(orphan)).isTrue();
+    }
+
+    @Test
+    public void testDistributedMarkReadsSharedMetadataOncePerPass() throws 
Exception {
+        FileStoreTable table = createManagedBlobTable();
+        StreamWriteBuilder writeBuilder = 
table.newStreamWriteBuilder().withCommitUser(commitUser);
+        write = writeBuilder.newWrite();
+        commit = writeBuilder.newCommit();
+        writeData(rowData(1, BinaryString.fromString("a"), new BlobData(new 
byte[] {1, 2})));
+        writeData(rowData(2, BinaryString.fromString("b"), new BlobData(new 
byte[] {3, 4})));
+        write.close();
+        commit.close();
+        write = null;
+        commit = null;
+        try (TableCommitImpl manifestCommit = 
table.newCommit("manifest-compact-test")) {
+            manifestCommit.compactManifests();
+        }
+        assertSharedManifestMetadata(table);
+        int parallelism = assertSharedSidecarAcrossManifestPartitions(table);
+
+        CountingManifestFileIO.reset();
+        FileStoreTable countingTable =
+                FileStoreTableFactory.create(
+                        new CountingManifestFileIO(), table.location(), 
table.schema());
+        StreamExecutionEnvironment env =
+                
streamExecutionEnvironmentBuilder().batchMode().parallelism(parallelism).build();
+        FlinkManagedBlobOrphanFilesClean cleaner =
+                new FlinkManagedBlobOrphanFilesClean(
+                        countingTable, Long.MAX_VALUE, true, parallelism);
+        try (CloseableIterator<CleanOrphanFilesResult> results =
+                cleaner.doClean(env).executeAndCollect()) {
+            while (results.hasNext()) {
+                results.next();
+            }
+        }
+
+        assertThat(CountingManifestFileIO.readCounts()).isNotEmpty();
+        assertThat(CountingManifestFileIO.readCounts().keySet())
+                .anyMatch(path -> 
path.endsWith(ManagedBlobReferenceFile.REFERENCE_FILE_SUFFIX));
+        
assertThat(CountingManifestFileIO.readCounts().values()).allMatch(count -> 
count == 2);
+    }
+
+    @Test
+    @Timeout(60)
+    public void testDistributedCleanupDoesNotHangWithPipelinedShuffle() throws 
Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        Path bucket = bucketPath(table);
+        List<Path> referenced =
+                filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        assertThat(referenced).isNotEmpty();
+        for (int i = 0; i < 256; i++) {
+            table.fileIO()
+                    .newOutputStream(new Path(bucket, "orphan-" + i + 
".managed.blob"), false)
+                    .close();
+        }
+        Thread.sleep(2000);
+
+        StreamExecutionEnvironment env =
+                streamExecutionEnvironmentBuilder()
+                        .batchMode()
+                        .parallelism(2)
+                        .setConf(
+                                ExecutionOptions.BATCH_SHUFFLE_MODE,
+                                BatchShuffleMode.ALL_EXCHANGES_PIPELINED)
+                        .build();
+        FlinkManagedBlobOrphanFilesClean cleaner =
+                new FlinkManagedBlobOrphanFilesClean(table, Long.MAX_VALUE, 
false, 2);
+        DataStream<CleanOrphanFilesResult> clean = cleaner.doClean(env);
+        
assertThat(env.getConfiguration().get(ExecutionOptions.BATCH_SHUFFLE_MODE))
+                .isEqualTo(BatchShuffleMode.ALL_EXCHANGES_BLOCKING);
+        long deleted = 0;
+        try (CloseableIterator<CleanOrphanFilesResult> results = 
clean.executeAndCollect()) {
+            while (results.hasNext()) {
+                deleted += results.next().getDeletedFileCount();
+            }
+        }
+        assertThat(deleted).isEqualTo(256);
+        assertThat(filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX))
+                .containsExactlyInAnyOrderElementsOf(referenced);
+    }
+
+    @Test
+    public void 
testDistributedDeletionAccountingSurvivesCleanupTaskRestartAfterDelete()
+            throws Exception {
+        FileStoreTable table = createManagedBlobTable();
+        Path bucket = bucketPath(table);
+        table.fileIO().mkdirs(bucket);
+        Path first = new Path(bucket, "first.managed.blob");
+        Path second = new Path(bucket, "second.managed.blob");
+        String firstContent = "one";
+        String secondContent = "second";
+        table.fileIO().writeFile(first, firstContent, false);
+        table.fileIO().writeFile(second, secondContent, false);
+        long expectedDeletedBytes = firstContent.length() + 
secondContent.length();
+        Thread.sleep(2000);
+
+        FAIL_AFTER_FIRST_DELETE.set(true);
+        FAILOVER_CLEANUP_RESULTS.clear();
+        StreamExecutionEnvironment env =
+                streamExecutionEnvironmentBuilder()
+                        .batchMode()
+                        .parallelism(1)
+                        .allowRestart()
+                        .build();
+        FlinkManagedBlobOrphanFilesClean cleaner = new 
FailAfterFirstDeleteCleaner(table);
+
+        // executeAndCollect() uses an uncheckpointed collect buffer that 
throws "Job restarted"
+        // on failover, so collect operator output through a MiniCluster-local 
sink instead.
+        cleaner.doClean(env).map(new CollectCleanupResult()).sinkTo(new 
DiscardingSink<>());
+        env.execute();
+
+        long deleted = 0;
+        long deletedBytes = 0;
+        for (CleanOrphanFilesResult result : FAILOVER_CLEANUP_RESULTS) {
+            deleted += result.getDeletedFileCount();
+            deletedBytes += result.getDeletedFileTotalLenInBytes();
+        }
+
+        assertThat(FAIL_AFTER_FIRST_DELETE).isFalse();
+        assertThat(deleted).isEqualTo(2);
+        assertThat(deletedBytes).isEqualTo(expectedDeletedBytes);
+        assertThat(table.fileIO().exists(first)).isFalse();
+        assertThat(table.fileIO().exists(second)).isFalse();
+    }
+
+    @Test
+    public void testDistributedCanonicalAliasCandidatesAreCountedOnce() throws 
Exception {
+        FileStoreTable table = createManagedBlobTable();
+        Path bucket = bucketPath(table);
+        table.fileIO().mkdirs(bucket);
+        Path orphan = new Path(bucket, "duplicate.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        FileStoreTable duplicateListingTable =
+                FileStoreTableFactory.create(
+                        new CanonicalAliasListingFileIO(), table.location(), 
table.schema());
+        StreamExecutionEnvironment env =
+                
streamExecutionEnvironmentBuilder().batchMode().parallelism(2).build();
+        FlinkManagedBlobOrphanFilesClean cleaner =
+                new FlinkManagedBlobOrphanFilesClean(
+                        duplicateListingTable, Long.MAX_VALUE, false, 2);
+
+        long deleted = 0;
+        try (CloseableIterator<CleanOrphanFilesResult> results =
+                cleaner.doClean(env).executeAndCollect()) {
+            while (results.hasNext()) {
+                deleted += results.next().getDeletedFileCount();
+            }
+        }
+
+        assertThat(deleted).isEqualTo(1);
+        assertThat(table.fileIO().exists(orphan)).isFalse();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void testActionFactoryDryRunParsing(boolean dryRun) throws 
Exception {
+        FileStoreTable table = createManagedBlobTable();
+        Path bucket = bucketPath(table);
+        table.fileIO().mkdirs(bucket);
+        Path orphan = new Path(bucket, "orphan.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        createAction(
+                        RemoveOrphanBlobsAction.class,
+                        "remove_orphan_blobs",
+                        "--warehouse",
+                        warehouse,
+                        "--database",
+                        database,
+                        "--table",
+                        tableName,
+                        "--older_than",
+                        currentTimestamp(),
+                        "--dry_run",
+                        String.valueOf(dryRun),
+                        "--parallelism",
+                        "2")
+                .run();
+
+        assertThat(table.fileIO().exists(orphan)).isEqualTo(dryRun);
+    }
+
+    @Test
+    public void testActionFactoryRejectsInvalidDryRun() {
+        assertThatThrownBy(
+                        () ->
+                                createAction(
+                                        RemoveOrphanBlobsAction.class,
+                                        "remove_orphan_blobs",
+                                        "--warehouse",
+                                        warehouse,
+                                        "--database",
+                                        database,
+                                        "--table",
+                                        tableName,
+                                        "--dry_run",
+                                        "ture"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(
+                        "Argument 'dry_run' must be either 'true' or 'false', 
but was 'ture'.");
+    }
+
+    @Test
+    public void testActionFactoryRejectsNonPositiveParallelism() {
+        assertThatThrownBy(
+                        () ->
+                                createAction(
+                                        RemoveOrphanBlobsAction.class,
+                                        "remove_orphan_blobs",
+                                        "--warehouse",
+                                        warehouse,
+                                        "--database",
+                                        database,
+                                        "--table",
+                                        tableName,
+                                        "--parallelism",
+                                        "0"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Parallelism must be greater than 0, but 
was 0.");
+    }
+
+    @Test
+    public void testProcedureRejectsNonPositiveParallelism() {
+        String call =
+                supportNamedArgument()
+                        ? String.format(
+                                "CALL sys.remove_orphan_blobs(`table` => 
'%s.%s', parallelism => 0)",
+                                database, tableName)
+                        : String.format(
+                                "CALL sys.remove_orphan_blobs('%s.%s', '', 
false, 0)",
+                                database, tableName);
+
+        assertThatThrownBy(() -> ImmutableList.copyOf(executeSQL(call)))
+                .satisfies(
+                        anyCauseMatches(
+                                IllegalArgumentException.class,
+                                "Parallelism must be greater than 0, but was 
0."));
+    }
+
+    @Test
+    public void testFrozenUsedMarkIsNotRecomputedDuringDeletion() throws 
Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        List<Path> referenced =
+                filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        assertThat(referenced).isNotEmpty();
+        Path orphan = new Path(bucketPath(table), "orphan.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+
+        MARK_PASS.set(0);
+        FROZEN_USED_NAMES.clear();
+        for (Path pack : referenced) {
+            
FROZEN_USED_NAMES.add(ManagedBlobOrphanFilesClean.packIdentity(pack));
+        }
+        StreamExecutionEnvironment env =
+                
streamExecutionEnvironmentBuilder().batchMode().parallelism(2).build();
+        FlinkManagedBlobOrphanFilesClean cleaner = new 
FreezeAfterTwoMarksCleaner(table);
+
+        long deleted = 0;
+        try (CloseableIterator<CleanOrphanFilesResult> results =
+                cleaner.doClean(env).executeAndCollect()) {
+            while (results.hasNext()) {
+                deleted += results.next().getDeletedFileCount();
+            }
+        }
+
+        assertThat(MARK_PASS.get()).isEqualTo(2);
+        assertThat(deleted).isEqualTo(1);
+        assertThat(table.fileIO().exists(orphan)).isFalse();
+        for (Path pack : referenced) {
+            assertThat(table.fileIO().exists(pack)).isTrue();
+        }
+    }
+
+    @Test
+    public void testUsedPackSetChangeSkipsGc() throws Exception {
+        FileStoreTable table = createManagedBlobTableAndWrite();
+        Path orphan = new Path(bucketPath(table), 
"used-set-changed.managed.blob");
+        table.fileIO().newOutputStream(orphan, false).close();
+        Thread.sleep(2000);
+        List<Path> referenced =
+                filesWithSuffix(table, 
ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        referenced.removeIf(path -> orphan.getName().equals(path.getName()));
+        assertThat(referenced).isNotEmpty();
+
+        MARK_PASS.set(0);
+        StreamExecutionEnvironment env =
+                
streamExecutionEnvironmentBuilder().batchMode().parallelism(2).build();
+        FlinkManagedBlobOrphanFilesClean cleaner = new 
ChangingUsedMarkCleaner(table);
+
+        long deleted = 0;
+        try (CloseableIterator<CleanOrphanFilesResult> results =
+                cleaner.doClean(env).executeAndCollect()) {
+            while (results.hasNext()) {
+                deleted += results.next().getDeletedFileCount();
+            }
+        }
+
+        assertThat(deleted).isZero();
+        assertThat(table.fileIO().exists(orphan)).isTrue();
+        for (Path pack : referenced) {
+            assertThat(table.fileIO().exists(pack)).isTrue();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"local", "distributed"})
+    public void testRemoveDatabaseOrphanBlobs(String mode) throws Exception {
+        FileStoreTable table1 = createManagedBlobTableAndWrite("T1");
+        FileStoreTable table2 = createManagedBlobTableAndWrite("T2");
+        try {
+            Path orphan1 = new Path(bucketPath(table1), "orphan.managed.blob");
+            Path orphan2 = new Path(bucketPath(table2), "orphan.managed.blob");
+            table1.fileIO().writeFile(orphan1, "abc", false);
+            table2.fileIO().writeFile(orphan2, "abc", false);
+            Thread.sleep(2000);
+
+            String olderThan = currentTimestamp();
+            String call =
+                    supportNamedArgument()
+                            ? String.format(
+                                    "CALL sys.remove_orphan_blobs(`table` => 
'%s.*', older_than => '%s', mode => '%s')",
+                                    database, olderThan, mode)
+                            : String.format(
+                                    "CALL sys.remove_orphan_blobs('%s.*', 
'%s', false, 5, '%s')",
+                                    database, olderThan, mode);
+            assertThat(ImmutableList.copyOf(executeSQL(call)))
+                    .containsExactly(Row.of("2"), Row.of("6"));
+            assertThat(table1.fileIO().exists(orphan1)).isFalse();
+            assertThat(table2.fileIO().exists(orphan2)).isFalse();
+        } finally {
+            catalog.dropTable(Identifier.create(database, "T1"), true);
+            catalog.dropTable(Identifier.create(database, "T2"), true);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"local", "distributed"})
+    public void testProcedureDryRun(String mode) throws Exception {
+        FileStoreTable table = createManagedBlobTable();
+        Path bucket = bucketPath(table);
+        table.fileIO().mkdirs(bucket);
+        Path orphan = new Path(bucket, "orphan.managed.blob");
+        table.fileIO().writeFile(orphan, "abc", false);
+        Thread.sleep(2000);
+
+        String olderThan = currentTimestamp();
+        String call =
+                supportNamedArgument()
+                        ? String.format(
+                                "CALL sys.remove_orphan_blobs(`table` => 
'%s.%s', older_than => '%s', dry_run => true, mode => '%s')",
+                                database, tableName, olderThan, mode)
+                        : String.format(
+                                "CALL sys.remove_orphan_blobs('%s.%s', '%s', 
true, 5, '%s')",
+                                database, tableName, olderThan, mode);
+        assertThat(ImmutableList.copyOf(executeSQL(call)))
+                .containsExactly(Row.of("1"), Row.of("3"));
+        assertThat(table.fileIO().exists(orphan)).isTrue();
+    }
+
+    private FileStoreTable createManagedBlobTableAndWrite() throws Exception {
+        return createManagedBlobTableAndWrite(tableName);
+    }
+
+    private FileStoreTable createManagedBlobTableAndWrite(String name) throws 
Exception {
+        FileStoreTable table = createManagedBlobTable(name);
+        StreamWriteBuilder writeBuilder = 
table.newStreamWriteBuilder().withCommitUser(commitUser);
+        write = writeBuilder.newWrite();
+        commit = writeBuilder.newCommit();
+        writeData(rowData(1, BinaryString.fromString("a"), new BlobData(new 
byte[] {1, 2})));
+        write.close();
+        commit.close();
+        write = null;
+        commit = null;
+        return table;
+    }
+
+    private FileStoreTable createManagedBlobTable() throws Exception {
+        return createManagedBlobTable(tableName);
+    }
+
+    private FileStoreTable createManagedBlobTable(String name) throws 
Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BLOB_FIELD.key(), "payload");
+        options.put(CoreOptions.CHANGELOG_PRODUCER.key(), "none");
+        options.put("bucket", "1");
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.STRING(), 
DataTypes.BLOB()},
+                        new String[] {"id", "name", "payload"});
+        return createFileStoreTable(
+                name,
+                rowType,
+                Collections.emptyList(),
+                Collections.singletonList("id"),
+                Collections.emptyList(),
+                options);
+    }
+
+    private static SingleOutputStreamOperator<String> emitUsedNames(
+            StreamExecutionEnvironment env, List<String> names) {
+        List<String> source = names.isEmpty() ? Collections.singletonList("") 
: names;
+        return env.fromCollection(source, TypeInformation.of(String.class))
+                .process(
+                        new ProcessFunction<String, String>() {
+                            @Override
+                            public void processElement(
+                                    String name, Context ctx, 
Collector<String> out) {
+                                if (!names.isEmpty()) {
+                                    out.collect(name);
+                                }
+                            }
+                        });
+    }
+
+    private String removeOrphanBlobsCall(String mode) {
+        String olderThan = currentTimestamp();
+        if (supportNamedArgument()) {
+            return String.format(
+                    "CALL sys.remove_orphan_blobs(`table` => '%s.%s', 
older_than => '%s', parallelism => 5, mode => '%s')",
+                    database, tableName, olderThan, mode);
+        }
+        return String.format(
+                "CALL sys.remove_orphan_blobs('%s.%s', '%s', false, 5, '%s')",
+                database, tableName, olderThan, mode);
+    }
+
+    private static String currentTimestamp() {
+        return DateTimeUtils.formatLocalDateTime(
+                DateTimeUtils.toLocalDateTime(System.currentTimeMillis()), 3);
+    }
+
+    private static Path bucketPath(FileStoreTable table) {
+        return table.store().pathFactory().bucketPath(BinaryRow.EMPTY_ROW, 0);
+    }
+
+    private static List<Path> filesWithSuffix(FileStoreTable table, String 
suffix)
+            throws IOException {
+        List<Path> result = new ArrayList<>();
+        FileStatus[] statuses = table.fileIO().listStatus(bucketPath(table));
+        if (statuses == null) {
+            return result;
+        }
+        for (FileStatus status : statuses) {
+            if (status.getPath().getName().endsWith(suffix)) {
+                result.add(status.getPath());
+            }
+        }
+        return result;
+    }
+
+    private static void deleteFilesWithSuffix(FileStoreTable table, String 
suffix)
+            throws IOException {
+        for (Path path : filesWithSuffix(table, suffix)) {
+            table.fileIO().deleteQuietly(path);
+        }
+    }
+
+    private static void assertSharedManifestMetadata(FileStoreTable table) 
throws IOException {
+        ManifestList manifestList = 
table.store().manifestListFactory().create();
+        Map<String, Integer> references = new HashMap<>();
+        Set<String> readLists = new HashSet<>();
+        Iterator<Snapshot> snapshots = table.snapshotManager().snapshots();
+        while (snapshots.hasNext()) {
+            Snapshot snapshot = snapshots.next();
+            String[] listNames = {
+                snapshot.changelogManifestList(),
+                snapshot.deltaManifestList(),
+                snapshot.baseManifestList()
+            };
+            for (String listName : listNames) {
+                if (listName == null) {
+                    continue;
+                }
+                references.merge("list:" + listName, 1, Integer::sum);
+                if (readLists.add(listName)) {
+                    for (ManifestFileMeta meta : 
manifestList.readWithIOException(listName)) {
+                        references.merge("manifest:" + meta.fileName(), 1, 
Integer::sum);
+                    }
+                }
+            }
+        }
+        assertThat(references.values()).anyMatch(count -> count > 1);
+    }
+
+    private static int 
assertSharedSidecarAcrossManifestPartitions(FileStoreTable table)
+            throws IOException {
+        ManifestList manifestList = 
table.store().manifestListFactory().create();
+        ManifestFile manifestFile = 
table.store().manifestFileFactory().create();
+        DataFilePathFactories pathFactories =
+                new DataFilePathFactories(table.store().pathFactory());
+        Set<String> readLists = new HashSet<>();
+        Set<String> readManifests = new HashSet<>();
+        Map<String, Set<String>> sidecarManifests = new HashMap<>();
+        Iterator<Snapshot> snapshots = table.snapshotManager().snapshots();
+        while (snapshots.hasNext()) {
+            Snapshot snapshot = snapshots.next();
+            String[] listNames = {
+                snapshot.changelogManifestList(),
+                snapshot.deltaManifestList(),
+                snapshot.baseManifestList()
+            };
+            for (String listName : listNames) {
+                if (listName == null || !readLists.add(listName)) {
+                    continue;
+                }
+                for (ManifestFileMeta meta : 
manifestList.readWithIOException(listName)) {
+                    if (!readManifests.add(meta.fileName())) {
+                        continue;
+                    }
+                    for (ManifestEntry entry : 
manifestFile.readWithIOException(meta.fileName())) {
+                        if (entry.kind() != FileKind.ADD || 
entry.file().extraFiles() == null) {
+                            continue;
+                        }
+                        Path dataFile =
+                                pathFactories.get(entry.partition(), 
entry.bucket()).toPath(entry);
+                        for (String extraFile : entry.file().extraFiles()) {
+                            if (extraFile != null
+                                    && extraFile.endsWith(
+                                            
ManagedBlobReferenceFile.REFERENCE_FILE_SUFFIX)) {
+                                String sidecar =
+                                        new Path(dataFile.getParent(), 
extraFile)
+                                                .toUri()
+                                                .normalize()
+                                                .toString();
+                                sidecarManifests
+                                        .computeIfAbsent(sidecar, ignored -> 
new HashSet<>())
+                                        .add(meta.fileName());
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        assertThat(sidecarManifests.values()).anyMatch(manifests -> 
manifests.size() > 1);
+        for (int parallelism = 2; parallelism <= 16; parallelism++) {
+            int maxParallelism = 
KeyGroupRangeAssignment.computeDefaultMaxParallelism(parallelism);
+            for (Set<String> manifests : sidecarManifests.values()) {
+                Set<Integer> partitions = new HashSet<>();
+                for (String manifest : manifests) {
+                    partitions.add(
+                            
KeyGroupRangeAssignment.assignKeyToParallelOperator(
+                                    DEFAULT_MAIN_BRANCH + '\0' + manifest,
+                                    maxParallelism,
+                                    parallelism));
+                }
+                if (partitions.size() > 1) {
+                    return parallelism;
+                }
+            }
+        }
+        throw new AssertionError(
+                "Shared sidecars did not span manifest-reader partitions for 
parallelism 2-16.");
+    }
+
+    private static class CountingManifestFileIO extends LocalFileIO {
+
+        private static final Map<String, AtomicInteger> READ_COUNTS = new 
ConcurrentHashMap<>();
+
+        @Override
+        public SeekableInputStream newInputStream(Path path) throws 
IOException {
+            String name = path.getName();
+            if (name.startsWith("manifest-list-")
+                    || (name.startsWith("manifest-") && 
!name.startsWith("manifest-list-"))
+                    || 
name.endsWith(ManagedBlobReferenceFile.REFERENCE_FILE_SUFFIX)) {
+                READ_COUNTS
+                        .computeIfAbsent(path.toUri().getPath(), ignored -> 
new AtomicInteger())
+                        .incrementAndGet();
+            }
+            return super.newInputStream(path);
+        }
+
+        private static Map<String, Integer> readCounts() {
+            Map<String, Integer> result = new HashMap<>();
+            READ_COUNTS.forEach((path, count) -> result.put(path, 
count.get()));
+            return result;
+        }
+
+        private static void reset() {
+            READ_COUNTS.clear();
+        }
+    }
+
+    private static class UnresolvableRelativeListingFileIO extends 
TraceableFileIO {
+
+        private static final AtomicInteger RESOLVE_ATTEMPTS = new 
AtomicInteger();
+
+        @Override
+        public FileStatus[] listStatus(Path path) throws IOException {
+            FileStatus[] statuses = super.listStatus(path);
+            if (statuses == null) {
+                return null;
+            }
+            FileStatus[] relative = new FileStatus[statuses.length];
+            for (int i = 0; i < statuses.length; i++) {
+                FileStatus status = statuses[i];
+                relative[i] =
+                        status.getPath()
+                                        .getName()
+                                        
.endsWith(ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX)
+                                ? withPath(status, new 
Path(status.getPath().getName()))
+                                : status;
+            }
+            return relative;
+        }
+
+        @Override
+        public FileStatus getFileStatus(Path path) throws IOException {
+            if (!path.toUri().getPath().startsWith("/")) {
+                RESOLVE_ATTEMPTS.incrementAndGet();
+                throw new IOException("Cannot resolve relative path " + path);
+            }
+            return super.getFileStatus(path);
+        }
+
+        private static int resolveAttempts() {
+            return RESOLVE_ATTEMPTS.get();
+        }
+
+        private static void reset() {
+            RESOLVE_ATTEMPTS.set(0);
+        }
+
+        private static FileStatus withPath(FileStatus status, Path path) {
+            return new FileStatus() {
+                @Override
+                public long getLen() {
+                    return status.getLen();
+                }
+
+                @Override
+                public boolean isDir() {
+                    return status.isDir();
+                }
+
+                @Override
+                public Path getPath() {
+                    return path;
+                }
+
+                @Override
+                public long getModificationTime() {
+                    return status.getModificationTime();
+                }
+            };
+        }
+    }
+
+    private static class CanonicalAliasListingFileIO extends TraceableFileIO {
+
+        @Override
+        public FileStatus[] listStatus(Path path) throws IOException {
+            FileStatus[] statuses = super.listStatus(path);
+            if (statuses == null) {
+                return null;
+            }
+            List<FileStatus> duplicated = new ArrayList<>();
+            for (FileStatus status : statuses) {
+                duplicated.add(status);
+                if (status.getPath()
+                        .getName()
+                        
.endsWith(ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX)) {
+                    Path alias =
+                            new Path(
+                                    "hdfs://duplicate-listing"
+                                            + 
status.getPath().toUri().getPath());
+                    
duplicated.add(UnresolvableRelativeListingFileIO.withPath(status, alias));
+                }
+            }
+            return duplicated.toArray(new FileStatus[0]);
+        }
+    }
+
+    private static class FreezeAfterTwoMarksCleaner extends 
FlinkManagedBlobOrphanFilesClean {
+
+        private FreezeAfterTwoMarksCleaner(FileStoreTable table) {
+            super(table, Long.MAX_VALUE, false, 2);
+        }
+
+        @Override
+        protected SingleOutputStreamOperator<String> collectUsedPacks(
+                DataStream<Tuple2<String, String>> manifestLists,
+                OutputTag<Boolean> skipGcTag,
+                String markName) {
+            int pass = MARK_PASS.incrementAndGet();
+            List<String> names =
+                    pass <= 2 ? new ArrayList<String>(FROZEN_USED_NAMES) : 
Collections.emptyList();
+            return emitUsedNames(manifestLists.getExecutionEnvironment(), 
names);
+        }
+    }
+
+    private static class ChangingUsedMarkCleaner extends 
FlinkManagedBlobOrphanFilesClean {
+
+        private ChangingUsedMarkCleaner(FileStoreTable table) {
+            super(table, Long.MAX_VALUE, false, 2);
+        }
+
+        @Override
+        protected SingleOutputStreamOperator<String> collectUsedPacks(
+                DataStream<Tuple2<String, String>> manifestLists,
+                OutputTag<Boolean> skipGcTag,
+                String markName) {
+            String name = MARK_PASS.incrementAndGet() == 1 ? "first-pack" : 
"second-pack";
+            return emitUsedNames(
+                    manifestLists.getExecutionEnvironment(), 
Collections.singletonList(name));
+        }
+    }
+
+    private static class FailAfterFirstDeleteCleaner extends 
FlinkManagedBlobOrphanFilesClean {
+
+        private FailAfterFirstDeleteCleaner(FileStoreTable table) {
+            super(table, Long.MAX_VALUE, false, 1);
+        }
+
+        @Override
+        protected boolean cleanPack(Path path) {
+            boolean cleaned = super.cleanPack(path);
+            if (cleaned && FAIL_AFTER_FIRST_DELETE.compareAndSet(true, false)) 
{
+                throw new RuntimeException("Injected failure after delete.");
+            }
+            return cleaned;
+        }
+    }
+
+    private static class CollectCleanupResult
+            implements MapFunction<CleanOrphanFilesResult, 
CleanOrphanFilesResult> {
+
+        @Override
+        public CleanOrphanFilesResult map(CleanOrphanFilesResult value) {
+            FAILOVER_CLEANUP_RESULTS.add(value);
+            return value;
+        }
+    }
+
+    protected boolean supportNamedArgument() {
+        return true;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanFilesActionITCaseBase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanFilesActionITCaseBase.java
index d65a2713a0..eafa3383d8 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanFilesActionITCaseBase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RemoveOrphanFilesActionITCaseBase.java
@@ -21,9 +21,11 @@ package org.apache.paimon.flink.action;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.orphan.FlinkOrphanFilesClean;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.FileStatus;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.operation.CleanOrphanFilesResult;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.FileSystemSchemaManager;
 import org.apache.paimon.schema.SchemaChange;
@@ -41,6 +43,10 @@ import org.apache.paimon.utils.DateTimeUtils;
 
 import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList;
 
+import org.apache.flink.api.common.BatchShuffleMode;
+import org.apache.flink.configuration.ExecutionOptions;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.flink.types.Row;
 import org.apache.flink.util.CloseableIterator;
 import org.junit.jupiter.api.Test;
@@ -512,6 +518,24 @@ public abstract class RemoveOrphanFilesActionITCaseBase 
extends ActionITCaseBase
         assertThat(fileIO.exists(new Path(nonEmptyPath, 
"guard.txt"))).isTrue();
     }
 
+    @Test
+    public void testDistributedCleanupForcesBlockingShuffle() throws Exception 
{
+        FileStoreTable table = createTableAndWriteData(tableName);
+        StreamExecutionEnvironment env =
+                streamExecutionEnvironmentBuilder()
+                        .batchMode()
+                        .parallelism(2)
+                        .setConf(
+                                ExecutionOptions.BATCH_SHUFFLE_MODE,
+                                BatchShuffleMode.ALL_EXCHANGES_PIPELINED)
+                        .build();
+        DataStream<CleanOrphanFilesResult> clean =
+                new FlinkOrphanFilesClean(table, Long.MAX_VALUE, false, 
2).doOrphanClean(env);
+        
assertThat(env.getConfiguration().get(ExecutionOptions.BATCH_SHUFFLE_MODE))
+                .isEqualTo(BatchShuffleMode.ALL_EXCHANGES_BLOCKING);
+        assertThat(clean).isNotNull();
+    }
+
     protected boolean supportNamedArgument() {
         return true;
     }

Reply via email to