This is an automated email from the ASF dual-hosted git repository.

wuchong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 96a63d5e8 [rpc][server][client] add ListRemoteLogManifests and 
ListKvSnapshots RPCs for orphan cleanup (#3480)
96a63d5e8 is described below

commit 96a63d5e8d7f5a4a8a07d6815f27b4c37d41726c
Author: Yang Wang <[email protected]>
AuthorDate: Sun Jun 14 23:53:12 2026 +0800

    [rpc][server][client] add ListRemoteLogManifests and ListKvSnapshots RPCs 
for orphan cleanup (#3480)
---
 .../java/org/apache/fluss/client/admin/Admin.java  |  21 ++
 .../org/apache/fluss/client/admin/FlussAdmin.java  |  34 +++
 .../fluss/client/metadata/ActiveKvSnapshots.java   |  65 ++++++
 .../client/metadata/RemoteLogManifestInfo.java     |  54 +++++
 .../fluss/client/utils/ClientRpcMessageUtils.java  |  34 +++
 .../flink/sink/testutils/TestAdminAdapter.java     |  14 ++
 .../org/apache/fluss/rpc/gateway/AdminGateway.java |  29 +++
 .../org/apache/fluss/rpc/protocol/ApiKeys.java     |   4 +-
 fluss-rpc/src/main/proto/FlussApi.proto            |  30 +++
 .../coordinator/CompletedSnapshotStoreManager.java |  53 +++++
 .../coordinator/CoordinatorEventProcessor.java     |   1 -
 .../server/coordinator/CoordinatorService.java     | 169 ++++++++++++++
 .../coordinator/lease/KvSnapshotLeaseManager.java  |  53 +++++
 .../server/kv/snapshot/CompletedSnapshotStore.java |  21 ++
 .../fluss/server/utils/ServerRpcMessageUtils.java  |  21 ++
 .../apache/fluss/server/zk/ZooKeeperClient.java    |  65 ++++++
 .../CoordinatorServiceOrphanRpcsITCase.java        | 259 +++++++++++++++++++++
 .../server/coordinator/TestCoordinatorGateway.java |  16 ++
 18 files changed, 941 insertions(+), 2 deletions(-)

diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
index 5f0bcbde5..6d60e4098 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
@@ -18,9 +18,11 @@
 package org.apache.fluss.client.admin;
 
 import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.client.metadata.ActiveKvSnapshots;
 import org.apache.fluss.client.metadata.KvSnapshotMetadata;
 import org.apache.fluss.client.metadata.KvSnapshots;
 import org.apache.fluss.client.metadata.LakeSnapshot;
+import org.apache.fluss.client.metadata.RemoteLogManifestInfo;
 import org.apache.fluss.cluster.ServerNode;
 import org.apache.fluss.cluster.rebalance.GoalType;
 import org.apache.fluss.cluster.rebalance.RebalanceProgress;
@@ -796,4 +798,23 @@ public interface Admin extends AutoCloseable {
      * @since 1.0
      */
     CompletableFuture<ClusterHealth> getClusterHealth();
+
+    /**
+     * List per-bucket remote log manifest entries for a table or partition 
scope.
+     *
+     * @param tableId the table to query
+     * @param partitionId optional partition id (null for non-partitioned 
tables)
+     * @return per-bucket manifest paths and end offsets
+     */
+    CompletableFuture<List<RemoteLogManifestInfo>> listRemoteLogManifests(
+            long tableId, @Nullable Long partitionId);
+
+    /**
+     * List per-bucket active KV snapshot ids for a table or partition scope.
+     *
+     * @param tableId the table to query
+     * @param partitionId optional partition id (null for non-partitioned 
tables)
+     * @return per-bucket active snapshot ids grouped by bucket
+     */
+    CompletableFuture<ActiveKvSnapshots> listKvSnapshots(long tableId, 
@Nullable Long partitionId);
 }
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
index f724a15b8..80f5d83e5 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
@@ -18,10 +18,12 @@
 package org.apache.fluss.client.admin;
 
 import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.client.metadata.ActiveKvSnapshots;
 import org.apache.fluss.client.metadata.KvSnapshotMetadata;
 import org.apache.fluss.client.metadata.KvSnapshots;
 import org.apache.fluss.client.metadata.LakeSnapshot;
 import org.apache.fluss.client.metadata.MetadataUpdater;
+import org.apache.fluss.client.metadata.RemoteLogManifestInfo;
 import org.apache.fluss.client.utils.ClientRpcMessageUtils;
 import org.apache.fluss.cluster.Cluster;
 import org.apache.fluss.cluster.ServerNode;
@@ -81,10 +83,12 @@ import org.apache.fluss.rpc.messages.GetTableStatsResponse;
 import org.apache.fluss.rpc.messages.ListAclsRequest;
 import org.apache.fluss.rpc.messages.ListDatabasesRequest;
 import org.apache.fluss.rpc.messages.ListDatabasesResponse;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsRequest;
 import org.apache.fluss.rpc.messages.ListOffsetsRequest;
 import org.apache.fluss.rpc.messages.ListOffsetsResponse;
 import org.apache.fluss.rpc.messages.ListPartitionInfosRequest;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressRequest;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsRequest;
 import org.apache.fluss.rpc.messages.ListTablesRequest;
 import org.apache.fluss.rpc.messages.ListTablesResponse;
 import org.apache.fluss.rpc.messages.PbAlterConfig;
@@ -392,6 +396,36 @@ public class FlussAdmin implements Admin {
                 .thenApply(ClientRpcMessageUtils::toPartitionInfos);
     }
 
+    /**
+     * Returns per-bucket remote log manifest path for the given table or 
partition.
+     *
+     * <p>Used by the orphan cleanup action to construct the active manifest 
path set without
+     * relying on FS LIST + mtime selection.
+     */
+    @Override
+    public CompletableFuture<List<RemoteLogManifestInfo>> 
listRemoteLogManifests(
+            long tableId, @Nullable Long partitionId) {
+        ListRemoteLogManifestsRequest request = new 
ListRemoteLogManifestsRequest();
+        request.setTableId(tableId);
+        if (partitionId != null) {
+            request.setPartitionId(partitionId);
+        }
+        return gateway.listRemoteLogManifests(request)
+                .thenApply(ClientRpcMessageUtils::toRemoteLogManifestInfos);
+    }
+
+    @Override
+    public CompletableFuture<ActiveKvSnapshots> listKvSnapshots(
+            long tableId, @Nullable Long partitionId) {
+        ListKvSnapshotsRequest request = new ListKvSnapshotsRequest();
+        request.setTableId(tableId);
+        if (partitionId != null) {
+            request.setPartitionId(partitionId);
+        }
+        return gateway.listKvSnapshots(request)
+                .thenApply(ClientRpcMessageUtils::toActiveKvSnapshots);
+    }
+
     @Override
     public CompletableFuture<Void> createPartition(
             TablePath tablePath, PartitionSpec partitionSpec, boolean 
ignoreIfExists) {
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/metadata/ActiveKvSnapshots.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/ActiveKvSnapshots.java
new file mode 100644
index 000000000..3950c69ef
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/ActiveKvSnapshots.java
@@ -0,0 +1,65 @@
+/*
+ * 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.fluss.client.metadata;
+
+import org.apache.fluss.annotation.PublicEvolving;
+
+import javax.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Per-bucket active KV snapshot ids for a table or partition scope. The 
active set is the union of
+ * retained snapshots and lease-pinned (still-in-use) snapshots reported by 
the coordinator.
+ *
+ * @since 1.0
+ */
+@PublicEvolving
+public final class ActiveKvSnapshots {
+
+    private final long tableId;
+    @Nullable private final Long partitionId;
+    private final Map<Integer, Set<Long>> snapshotIdsByBucket;
+
+    public ActiveKvSnapshots(
+            long tableId, @Nullable Long partitionId, Map<Integer, Set<Long>> 
snapshotIdsByBucket) {
+        this.tableId = tableId;
+        this.partitionId = partitionId;
+        Map<Integer, Set<Long>> copy = new HashMap<>();
+        for (Map.Entry<Integer, Set<Long>> entry : 
snapshotIdsByBucket.entrySet()) {
+            copy.put(entry.getKey(), 
Collections.unmodifiableSet(entry.getValue()));
+        }
+        this.snapshotIdsByBucket = Collections.unmodifiableMap(copy);
+    }
+
+    public long getTableId() {
+        return tableId;
+    }
+
+    @Nullable
+    public Long getPartitionId() {
+        return partitionId;
+    }
+
+    public Map<Integer, Set<Long>> getSnapshotIdsByBucket() {
+        return snapshotIdsByBucket;
+    }
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/metadata/RemoteLogManifestInfo.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/RemoteLogManifestInfo.java
new file mode 100644
index 000000000..fa0341771
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/RemoteLogManifestInfo.java
@@ -0,0 +1,54 @@
+/*
+ * 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.fluss.client.metadata;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.metadata.TableBucket;
+
+/**
+ * A single remote log manifest entry returned by the coordinator. Each entry 
maps a {@link
+ * TableBucket} to its current manifest file path and the end offset covered 
by that manifest.
+ *
+ * @since 1.0
+ */
+@PublicEvolving
+public final class RemoteLogManifestInfo {
+
+    private final TableBucket tableBucket;
+    private final String remoteLogManifestPath;
+    private final long remoteLogEndOffset;
+
+    public RemoteLogManifestInfo(
+            TableBucket tableBucket, String remoteLogManifestPath, long 
remoteLogEndOffset) {
+        this.tableBucket = tableBucket;
+        this.remoteLogManifestPath = remoteLogManifestPath;
+        this.remoteLogEndOffset = remoteLogEndOffset;
+    }
+
+    public TableBucket getTableBucket() {
+        return tableBucket;
+    }
+
+    public String getRemoteLogManifestPath() {
+        return remoteLogManifestPath;
+    }
+
+    public long getRemoteLogEndOffset() {
+        return remoteLogEndOffset;
+    }
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
index 3f6e2443a..2df5b91ef 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
@@ -24,9 +24,11 @@ import org.apache.fluss.client.admin.ProducerOffsetsResult;
 import org.apache.fluss.client.lookup.LookupBatch;
 import org.apache.fluss.client.lookup.PrefixLookupBatch;
 import org.apache.fluss.client.metadata.AcquireKvSnapshotLeaseResult;
+import org.apache.fluss.client.metadata.ActiveKvSnapshots;
 import org.apache.fluss.client.metadata.KvSnapshotMetadata;
 import org.apache.fluss.client.metadata.KvSnapshots;
 import org.apache.fluss.client.metadata.LakeSnapshot;
+import org.apache.fluss.client.metadata.RemoteLogManifestInfo;
 import org.apache.fluss.client.write.KvWriteBatch;
 import org.apache.fluss.client.write.ReadyWriteBatch;
 import org.apache.fluss.cluster.rebalance.RebalancePlanForBucket;
@@ -61,9 +63,11 @@ import 
org.apache.fluss.rpc.messages.GetLatestKvSnapshotsResponse;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsResponse;
 import org.apache.fluss.rpc.messages.GetTableStatsRequest;
 import org.apache.fluss.rpc.messages.ListDatabasesResponse;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsResponse;
 import org.apache.fluss.rpc.messages.ListOffsetsRequest;
 import org.apache.fluss.rpc.messages.ListPartitionInfosResponse;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
 import org.apache.fluss.rpc.messages.LookupRequest;
 import org.apache.fluss.rpc.messages.MetadataRequest;
 import org.apache.fluss.rpc.messages.PbAddColumn;
@@ -87,6 +91,7 @@ import org.apache.fluss.rpc.messages.PbPutKvReqForBucket;
 import org.apache.fluss.rpc.messages.PbRebalancePlanForBucket;
 import org.apache.fluss.rpc.messages.PbRebalanceProgressForBucket;
 import org.apache.fluss.rpc.messages.PbRebalanceProgressForTable;
+import org.apache.fluss.rpc.messages.PbRemoteLogManifestEntry;
 import org.apache.fluss.rpc.messages.PbRemotePathAndLocalFile;
 import org.apache.fluss.rpc.messages.PbRenameColumn;
 import org.apache.fluss.rpc.messages.PbTableBucket;
@@ -106,6 +111,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -536,6 +542,34 @@ public class ClientRpcMessageUtils {
         return request;
     }
 
+    public static List<RemoteLogManifestInfo> toRemoteLogManifestInfos(
+            ListRemoteLogManifestsResponse response) {
+        List<RemoteLogManifestInfo> result = new 
ArrayList<>(response.getManifestsCount());
+        for (PbRemoteLogManifestEntry entry : response.getManifestsList()) {
+            PbTableBucket pb = entry.getTableBucket();
+            Long partitionId = pb.hasPartitionId() ? pb.getPartitionId() : 
null;
+            TableBucket tableBucket =
+                    new TableBucket(pb.getTableId(), partitionId, 
pb.getBucketId());
+            result.add(
+                    new RemoteLogManifestInfo(
+                            tableBucket,
+                            entry.getRemoteLogManifestPath(),
+                            entry.getRemoteLogEndOffset()));
+        }
+        return result;
+    }
+
+    public static ActiveKvSnapshots 
toActiveKvSnapshots(ListKvSnapshotsResponse response) {
+        Map<Integer, Set<Long>> snapshotIdsByBucket = new HashMap<>();
+        for (PbKvSnapshot snapshot : response.getActiveSnapshotsList()) {
+            snapshotIdsByBucket
+                    .computeIfAbsent(snapshot.getBucketId(), k -> new 
HashSet<>())
+                    .add(snapshot.getSnapshotId());
+        }
+        Long partitionId = response.hasPartitionId() ? 
response.getPartitionId() : null;
+        return new ActiveKvSnapshots(response.getTableId(), partitionId, 
snapshotIdsByBucket);
+    }
+
     public static Optional<RebalanceProgress> toRebalanceProgress(
             ListRebalanceProgressResponse response) {
         if (!response.hasRebalanceId()) {
diff --git 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
index 792187cc8..e8120c83d 100644
--- 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
+++ 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
@@ -26,9 +26,11 @@ import org.apache.fluss.client.admin.ListOffsetsResult;
 import org.apache.fluss.client.admin.OffsetSpec;
 import org.apache.fluss.client.admin.ProducerOffsetsResult;
 import org.apache.fluss.client.admin.RegisterResult;
+import org.apache.fluss.client.metadata.ActiveKvSnapshots;
 import org.apache.fluss.client.metadata.KvSnapshotMetadata;
 import org.apache.fluss.client.metadata.KvSnapshots;
 import org.apache.fluss.client.metadata.LakeSnapshot;
+import org.apache.fluss.client.metadata.RemoteLogManifestInfo;
 import org.apache.fluss.cluster.ServerNode;
 import org.apache.fluss.cluster.rebalance.GoalType;
 import org.apache.fluss.cluster.rebalance.RebalanceProgress;
@@ -323,4 +325,16 @@ public class TestAdminAdapter implements Admin {
     public CompletableFuture<ClusterHealth> getClusterHealth() {
         throw new UnsupportedOperationException("Not implemented in 
TestAdminAdapter");
     }
+
+    @Override
+    public CompletableFuture<List<RemoteLogManifestInfo>> 
listRemoteLogManifests(
+            long tableId, @Nullable Long partitionId) {
+        throw new UnsupportedOperationException("Not implemented in 
TestAdminAdapter");
+    }
+
+    @Override
+    public CompletableFuture<ActiveKvSnapshots> listKvSnapshots(
+            long tableId, @Nullable Long partitionId) {
+        throw new UnsupportedOperationException("Not implemented in 
TestAdminAdapter");
+    }
 }
diff --git 
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminGateway.java 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminGateway.java
index 21d286aa4..44c362a54 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminGateway.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminGateway.java
@@ -51,8 +51,12 @@ import org.apache.fluss.rpc.messages.DropTableRequest;
 import org.apache.fluss.rpc.messages.DropTableResponse;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsRequest;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsResponse;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsRequest;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsResponse;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressRequest;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsRequest;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
 import org.apache.fluss.rpc.messages.RebalanceRequest;
 import org.apache.fluss.rpc.messages.RebalanceResponse;
 import org.apache.fluss.rpc.messages.RegisterProducerOffsetsRequest;
@@ -216,4 +220,29 @@ public interface AdminGateway extends AdminReadOnlyGateway 
{
 
     // todo: rename table & alter table
 
+    // 
==================================================================================
+    // Orphan Cleanup RPCs (coordinator-only, internal)
+    // 
==================================================================================
+
+    /**
+     * List remote log manifest entries for all buckets of a table or single 
partition.
+     *
+     * @param request request with table_id and optional partition_id
+     * @return per-bucket manifest path and end offset
+     */
+    @RPC(api = ApiKeys.LIST_REMOTE_LOG_MANIFESTS)
+    CompletableFuture<ListRemoteLogManifestsResponse> listRemoteLogManifests(
+            ListRemoteLogManifestsRequest request);
+
+    /**
+     * List active KV snapshot ids for a (tableId, partitionId) unit. The 
response is the union of
+     * (a) snapshots currently held by the in-memory {@code 
CompletedSnapshotStore} for each bucket
+     * and (b) snapshots still pinned by an active KV snapshot lease. No 
retention truncation is
+     * applied — every snapshot the coordinator has not yet pruned from ZK is 
reported as active so
+     * orphan cleanup never misdeletes a still-referenced snapshot. The server 
emits one entry per
+     * active {@code (bucket_id, snapshot_id)} pair with no source 
discriminator; callers must treat
+     * the entire response as the active set.
+     */
+    @RPC(api = ApiKeys.LIST_KV_SNAPSHOTS)
+    CompletableFuture<ListKvSnapshotsResponse> 
listKvSnapshots(ListKvSnapshotsRequest request);
 }
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
index dd164d7cc..b03e1ec63 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
@@ -104,7 +104,9 @@ public enum ApiKeys {
     GET_TABLE_STATS(1059, 0, 0, PUBLIC),
     ALTER_DATABASE(1060, 0, 0, PUBLIC),
     SCAN_KV(1061, 0, 0, PUBLIC),
-    GET_CLUSTER_HEALTH(1062, 0, 0, PUBLIC);
+    GET_CLUSTER_HEALTH(1062, 0, 0, PUBLIC),
+    LIST_REMOTE_LOG_MANIFESTS(1063, 0, 0, PUBLIC),
+    LIST_KV_SNAPSHOTS(1064, 0, 0, PUBLIC);
 
     private static final Map<Integer, ApiKeys> ID_TO_TYPE =
             Arrays.stream(ApiKeys.values())
diff --git a/fluss-rpc/src/main/proto/FlussApi.proto 
b/fluss-rpc/src/main/proto/FlussApi.proto
index 1e66bc343..41c738777 100644
--- a/fluss-rpc/src/main/proto/FlussApi.proto
+++ b/fluss-rpc/src/main/proto/FlussApi.proto
@@ -506,6 +506,36 @@ message ListPartitionInfosResponse {
   repeated PbPartitionInfo partitions_info = 1;
 }
 
+// list remote log manifest entries (one per bucket of a table or partition)
+message ListRemoteLogManifestsRequest {
+  required int64 table_id = 1;
+  optional int64 partition_id = 2;   // required if table is partitioned
+}
+
+message ListRemoteLogManifestsResponse {
+  repeated PbRemoteLogManifestEntry manifests = 1;
+}
+
+message PbRemoteLogManifestEntry {
+  required PbTableBucket table_bucket = 1;
+  required string remote_log_manifest_path = 2;
+  required int64  remote_log_end_offset = 3;
+}
+
+// list active KV snapshot dirs (retained_N + still-in-use) for a unit
+message ListKvSnapshotsRequest {
+  required int64 table_id = 1;
+  optional int64 partition_id = 2;
+}
+
+message ListKvSnapshotsResponse {
+  required int64 table_id = 1;
+  // null if it is a non-partitioned table, otherwise, it must be not null
+  optional int64 partition_id = 2;
+  // Active snapshots = retained_N ∪ still-in-use; multiple entries per bucket 
allowed.
+  repeated PbKvSnapshot active_snapshots = 3;
+}
+
 // create partition request and response
 message CreatePartitionRequest {
   required PbTablePath table_path = 1;
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CompletedSnapshotStoreManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CompletedSnapshotStoreManager.java
index 6e483853c..f7c1b9bf6 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CompletedSnapshotStoreManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CompletedSnapshotStoreManager.java
@@ -18,6 +18,7 @@
 package org.apache.fluss.server.coordinator;
 
 import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.exception.ApiException;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.metrics.MetricNames;
@@ -35,9 +36,13 @@ import org.apache.fluss.server.zk.ZooKeeperClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 
 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.Set;
@@ -246,6 +251,54 @@ public class CompletedSnapshotStoreManager {
                 snapshotInUseChecker);
     }
 
+    /**
+     * Returns active snapshot IDs per bucket for the given (tableId, 
partitionId) scope. For
+     * buckets with an in-memory {@link CompletedSnapshotStore}, the cached 
active set is returned
+     * (completed snapshots ∪ still-in-use snapshots, no retention 
truncation). For other buckets,
+     * snapshot IDs are read directly from ZK children (no per-snapshot 
payload fetch).
+     */
+    public Map<Integer, Set<Long>> getActiveSnapshotIdsByBucket(
+            long tableId, @Nullable Long partitionId, int numBuckets) {
+        Map<Integer, Set<Long>> result = new HashMap<>();
+        for (int i = 0; i < numBuckets; i++) {
+            TableBucket tb = new TableBucket(tableId, partitionId, i);
+            CompletedSnapshotStore store = 
bucketCompletedSnapshotStores.get(tb);
+            Set<Long> ids;
+            if (store != null) {
+                ids = store.getActiveSnapshotIds();
+            } else {
+                ids = readActiveSnapshotIdsFromZk(tb);
+            }
+            if (!ids.isEmpty()) {
+                result.put(i, ids);
+            }
+        }
+        return result;
+    }
+
+    private Set<Long> readActiveSnapshotIdsFromZk(TableBucket tableBucket) {
+        List<Long> snapshotIds;
+        try {
+            snapshotIds = zooKeeperClient.listBucketSnapshotIds(tableBucket);
+        } catch (Exception e) {
+            throw new ApiException(
+                    "Failed to read snapshot IDs from ZK for "
+                            + tableBucket
+                            + ": "
+                            + e.getMessage(),
+                    e);
+        }
+        if (snapshotIds.isEmpty()) {
+            return Collections.emptySet();
+        }
+        // Treat every snapshot still referenced in ZK as active. As long as 
the
+        // coordinator has not pruned a snapshot's ZK handle, it is 
potentially in use
+        // (lease, in-flight RPC, recovery, etc.), so the orphan cleaner must 
keep its
+        // files. Orphan cleanup is conservative by design — a slightly 
broader active
+        // set is harmless, while excluding a still-referenced snapshot is not.
+        return new HashSet<>(snapshotIds);
+    }
+
     @VisibleForTesting
     Map<TableBucket, CompletedSnapshotStore> 
getBucketCompletedSnapshotStores() {
         return bucketCompletedSnapshotStores;
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
index 0a93ed54b..537e2df63 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
@@ -2344,7 +2344,6 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
                 coordinatorContext.getCoordinatorEpoch());
     }
 
-    @VisibleForTesting
     CompletedSnapshotStoreManager completedSnapshotStoreManager() {
         return completedSnapshotStoreManager;
     }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
index 8c122cb78..0c9d3c729 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
@@ -34,8 +34,10 @@ import org.apache.fluss.exception.InvalidDatabaseException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.LakeTableAlreadyExistException;
 import org.apache.fluss.exception.NonPrimaryKeyTableException;
+import org.apache.fluss.exception.PartitionNotExistException;
 import org.apache.fluss.exception.SecurityDisabledException;
 import org.apache.fluss.exception.TableAlreadyExistException;
+import org.apache.fluss.exception.TableNotExistException;
 import org.apache.fluss.exception.TableNotPartitionedException;
 import org.apache.fluss.exception.UnknownServerException;
 import org.apache.fluss.exception.UnknownTableOrBucketException;
@@ -104,8 +106,12 @@ import 
org.apache.fluss.rpc.messages.GetProducerOffsetsRequest;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsResponse;
 import org.apache.fluss.rpc.messages.LakeTieringHeartbeatRequest;
 import org.apache.fluss.rpc.messages.LakeTieringHeartbeatResponse;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsRequest;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsResponse;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressRequest;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsRequest;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
 import org.apache.fluss.rpc.messages.MetadataRequest;
 import org.apache.fluss.rpc.messages.MetadataResponse;
 import org.apache.fluss.rpc.messages.PbAlterConfig;
@@ -167,6 +173,7 @@ import 
org.apache.fluss.server.metadata.CoordinatorMetadataCache;
 import org.apache.fluss.server.metadata.CoordinatorMetadataProvider;
 import org.apache.fluss.server.utils.ServerRpcMessageUtils;
 import org.apache.fluss.server.zk.ZooKeeperClient;
+import org.apache.fluss.server.zk.ZooKeeperClient.TableBucketAndManifest;
 import org.apache.fluss.server.zk.data.BucketAssignment;
 import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.server.zk.data.PartitionAssignment;
@@ -187,8 +194,10 @@ import javax.annotation.Nullable;
 import java.io.UncheckedIOException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -215,6 +224,7 @@ import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.groupOffsetsBy
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeAcquireKvSnapshotLeaseResponse;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeCreateAclsResponse;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeDropAclsResponse;
+import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeListRemoteLogManifestsResponse;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.toAlterTableConfigChanges;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.toAlterTableSchemaChanges;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.toDatabaseChanges;
@@ -238,6 +248,7 @@ public final class CoordinatorService extends 
RpcServiceBase implements Coordina
     private final Supplier<Integer> coordinatorEpochSupplier;
     private final CoordinatorMetadataCache metadataCache;
 
+    private final Supplier<CompletedSnapshotStoreManager> 
snapshotStoreManagerSupplier;
     private final LakeTableTieringManager lakeTableTieringManager;
     private final LakeCatalogDynamicLoader lakeCatalogDynamicLoader;
     private final ExecutorService ioExecutor;
@@ -278,6 +289,8 @@ public final class CoordinatorService extends 
RpcServiceBase implements Coordina
                 () -> 
coordinatorEventProcessorSupplier.get().getCoordinatorEventManager();
         this.coordinatorEpochSupplier =
                 () -> 
coordinatorEventProcessorSupplier.get().getCoordinatorEpoch();
+        this.snapshotStoreManagerSupplier =
+                () -> 
coordinatorEventProcessorSupplier.get().completedSnapshotStoreManager();
         this.lakeTableTieringManager = lakeTableTieringManager;
         this.metadataCache = metadataCache;
         this.lakeCatalogDynamicLoader = lakeCatalogDynamicLoader;
@@ -785,6 +798,162 @@ public final class CoordinatorService extends 
RpcServiceBase implements Coordina
         return metadataResponseAccessContextEvent.getResultFuture();
     }
 
+    @Override
+    public CompletableFuture<ListRemoteLogManifestsResponse> 
listRemoteLogManifests(
+            ListRemoteLogManifestsRequest request) {
+        long tableId = request.getTableId();
+        if (authorizer != null) {
+            authorizeTableWithSession(currentSession(), 
OperationType.DESCRIBE, tableId);
+        }
+        return CompletableFuture.supplyAsync(
+                () -> {
+                    Long partitionId = request.hasPartitionId() ? 
request.getPartitionId() : null;
+                    validatePartitionOwnership(partitionId, tableId);
+                    try {
+                        List<TableBucketAndManifest> entries =
+                                zkClient.listRemoteLogManifestHandles(tableId, 
partitionId);
+                        return makeListRemoteLogManifestsResponse(entries);
+                    } catch (ApiException e) {
+                        throw e;
+                    } catch (Exception e) {
+                        throw new UnknownServerException(
+                                "Failed to list remote log manifests for 
tableId=" + tableId, e);
+                    }
+                },
+                ioExecutor);
+    }
+
+    @Override
+    public CompletableFuture<ListKvSnapshotsResponse> listKvSnapshots(
+            ListKvSnapshotsRequest request) {
+        long tableId = request.getTableId();
+        Long partitionId = request.hasPartitionId() ? request.getPartitionId() 
: null;
+        if (authorizer != null) {
+            authorizeTableWithSession(currentSession(), 
OperationType.DESCRIBE, tableId);
+        }
+
+        // Resolve numBuckets via event thread (CoordinatorContext is 
@NotThreadSafe)
+        CompletableFuture<Integer> numBucketsFuture = 
resolveNumBuckets(tableId, partitionId);
+
+        return numBucketsFuture.thenCompose(
+                numBuckets ->
+                        CompletableFuture.supplyAsync(
+                                () -> {
+                                    validatePartitionOwnership(partitionId, 
tableId);
+
+                                    CompletedSnapshotStoreManager storeManager 
=
+                                            snapshotStoreManagerSupplier.get();
+                                    Map<Integer, Set<Long>> activeByBucket =
+                                            
storeManager.getActiveSnapshotIdsByBucket(
+                                                    tableId, partitionId, 
numBuckets);
+                                    Map<Integer, Set<Long>> stillInUse =
+                                            
kvSnapshotLeaseManager.getStillInUseSnapshotIds(
+                                                    tableId, partitionId);
+
+                                    ListKvSnapshotsResponse response =
+                                            new 
ListKvSnapshotsResponse().setTableId(tableId);
+                                    if (partitionId != null) {
+                                        response.setPartitionId(partitionId);
+                                    }
+
+                                    Set<Integer> allBucketIds =
+                                            new 
HashSet<>(activeByBucket.keySet());
+                                    allBucketIds.addAll(stillInUse.keySet());
+                                    for (int bucketId : allBucketIds) {
+                                        Set<Long> merged =
+                                                new LinkedHashSet<>(
+                                                        
activeByBucket.getOrDefault(
+                                                                bucketId, 
Collections.emptySet()));
+                                        merged.addAll(
+                                                stillInUse.getOrDefault(
+                                                        bucketId, 
Collections.emptySet()));
+                                        for (Long snapId : merged) {
+                                            response.addActiveSnapshot()
+                                                    .setBucketId(bucketId)
+                                                    .setSnapshotId(snapId);
+                                        }
+                                    }
+                                    return response;
+                                },
+                                ioExecutor));
+    }
+
+    private CompletableFuture<Integer> resolveNumBuckets(long tableId, 
@Nullable Long partitionId) {
+        AccessContextEvent<Integer> event =
+                new AccessContextEvent<>(
+                        ctx -> {
+                            TablePath tablePath = 
ctx.getTablePathById(tableId);
+                            if (tablePath != null) {
+                                TableInfo tableInfo = 
ctx.getTableInfoById(tableId);
+                                if (tableInfo != null) {
+                                    return tableInfo.getNumBuckets();
+                                }
+                            }
+                            return null;
+                        });
+        eventManagerSupplier.get().put(event);
+        return event.getResultFuture()
+                .thenCompose(
+                        numBuckets -> {
+                            if (numBuckets != null) {
+                                return 
CompletableFuture.completedFuture(numBuckets);
+                            }
+                            return CompletableFuture.supplyAsync(
+                                    () -> resolveNumBucketsFromZk(tableId, 
partitionId),
+                                    ioExecutor);
+                        });
+    }
+
+    private int resolveNumBucketsFromZk(long tableId, @Nullable Long 
partitionId) {
+        try {
+            if (partitionId != null) {
+                Optional<PartitionAssignment> pAssignment =
+                        zkClient.getPartitionAssignment(partitionId);
+                if (!pAssignment.isPresent()) {
+                    throw new PartitionNotExistException(
+                            "Partition " + partitionId + " does not exist");
+                }
+                return pAssignment.get().getBuckets().size();
+            } else {
+                Optional<TableAssignment> assignment = 
zkClient.getTableAssignment(tableId);
+                if (!assignment.isPresent()) {
+                    throw new TableNotExistException("Table " + tableId + " 
does not exist");
+                }
+                return assignment.get().getBuckets().size();
+            }
+        } catch (ApiException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new ApiException("Failed to resolve table " + tableId + ": " 
+ e.getMessage(), e);
+        }
+    }
+
+    private void validatePartitionOwnership(@Nullable Long partitionId, long 
tableId) {
+        if (partitionId == null) {
+            return;
+        }
+        try {
+            Optional<PartitionAssignment> pa = 
zkClient.getPartitionAssignment(partitionId);
+            if (!pa.isPresent()) {
+                throw new PartitionNotExistException(
+                        "Partition " + partitionId + " does not exist");
+            }
+            if (pa.get().getTableId() != tableId) {
+                throw new PartitionNotExistException(
+                        "Partition " + partitionId + " does not belong to 
table " + tableId);
+            }
+        } catch (ApiException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new UnknownServerException(
+                    "Failed to validate partition ownership for partition "
+                            + partitionId
+                            + " and table "
+                            + tableId,
+                    e);
+        }
+    }
+
     @Override
     public CompletableFuture<AdjustIsrResponse> adjustIsr(AdjustIsrRequest 
request) {
         CompletableFuture<AdjustIsrResponse> response = new 
CompletableFuture<>();
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
index 97ec1dd1a..6c79a668e 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
@@ -32,14 +32,17 @@ import 
org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
 import java.util.ArrayList;
 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.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
@@ -311,6 +314,56 @@ public class KvSnapshotLeaseManager {
                 });
     }
 
+    /**
+     * Returns lease-pinned snapshot ids per bucket for the given (tableId, 
partitionId) unit. Used
+     * by the {@code ListKvSnapshots} RPC to expose STILL_IN_USE snapshots 
that fall outside the
+     * retained_N window of {@link
+     * 
org.apache.fluss.server.kv.snapshot.CompletedSnapshotStore#stillInUseSnapshots}.
+     *
+     * <p>Result map keys are bucket ids; values are the set of snapshot ids 
that the lease layer
+     * currently considers pinned for that bucket.
+     */
+    public Map<Integer, Set<Long>> getStillInUseSnapshotIds(
+            long tableId, @Nullable Long partitionId) {
+        return inReadLock(
+                managerLock,
+                () -> {
+                    Map<Integer, Set<Long>> result = new HashMap<>();
+                    for (KvSnapshotLeaseHandler leaseHandler : 
kvSnapshotLeaseMap.values()) {
+                        KvSnapshotTableLease tableLease =
+                                
leaseHandler.getTableIdToTableLease().get(tableId);
+                        if (tableLease == null) {
+                            continue;
+                        }
+                        Long[] snapshots;
+                        if (partitionId == null) {
+                            snapshots = tableLease.getBucketSnapshots();
+                        } else {
+                            Map<Long, Long[]> byPartition = 
tableLease.getPartitionSnapshots();
+                            if (byPartition == null) {
+                                continue;
+                            }
+                            snapshots = byPartition.get(partitionId);
+                        }
+                        if (snapshots == null) {
+                            continue;
+                        }
+                        // Invariant: `snapshots` is indexed by bucketId 
starting at 0, contiguous,
+                        // with length >= bucket count (auto-expanded by 
KvSnapshotLeaseHandler when
+                        // buckets are added). Slots not yet registered hold 
-1L (or null after
+                        // certain partitioned-table paths). See 
KvSnapshotLeaseHandler#addBucket
+                        // (bucketSnapshot[bucketId] = snapshotId) for the 
writer-side enforcement.
+                        for (int bucketId = 0; bucketId < snapshots.length; 
bucketId++) {
+                            Long snapId = snapshots[bucketId];
+                            if (snapId != null && snapId != -1L) {
+                                result.computeIfAbsent(bucketId, k -> new 
HashSet<>()).add(snapId);
+                            }
+                        }
+                    }
+                    return result;
+                });
+    }
+
     private void initializeRefCount(KvSnapshotLeaseHandler lease) {
         for (Map.Entry<Long, KvSnapshotTableLease> tableEntry :
                 lease.getTableIdToTableLease().entrySet()) {
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/kv/snapshot/CompletedSnapshotStore.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/snapshot/CompletedSnapshotStore.java
index cf398636b..7cf3e88db 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/kv/snapshot/CompletedSnapshotStore.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/snapshot/CompletedSnapshotStore.java
@@ -124,6 +124,27 @@ public class CompletedSnapshotStore {
         return inLock(lock, completedSnapshots::size);
     }
 
+    /**
+     * Returns the union of every snapshot still held by this store and the 
snapshots recorded as
+     * still-in-use. Any snapshot whose ZK handle has not yet been pruned is 
potentially active
+     * (lease, in-flight RPC, recovery, etc.), so the orphan cleaner must keep 
its files. Orphan
+     * cleanup is conservative by design — a slightly broader active set is 
harmless, while
+     * excluding a still-referenced snapshot is not.
+     */
+    public Set<Long> getActiveSnapshotIds() {
+        return inLock(
+                lock,
+                () -> {
+                    Set<Long> ids =
+                            new HashSet<>(completedSnapshots.size() + 
stillInUseSnapshots.size());
+                    for (CompletedSnapshot snapshot : completedSnapshots) {
+                        ids.add(snapshot.getSnapshotID());
+                    }
+                    ids.addAll(stillInUseSnapshots.keySet());
+                    return ids;
+                });
+    }
+
     /**
      * Synchronously writes the new snapshots to snapshot handle store and 
asynchronously removes
      * older ones.
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
index f7a89828a..266abe3bc 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
@@ -87,6 +87,7 @@ import org.apache.fluss.rpc.messages.ListOffsetsRequest;
 import org.apache.fluss.rpc.messages.ListOffsetsResponse;
 import org.apache.fluss.rpc.messages.ListPartitionInfosResponse;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
 import org.apache.fluss.rpc.messages.LookupRequest;
 import org.apache.fluss.rpc.messages.LookupResponse;
 import org.apache.fluss.rpc.messages.MetadataResponse;
@@ -141,6 +142,7 @@ import org.apache.fluss.rpc.messages.PbPutKvReqForBucket;
 import org.apache.fluss.rpc.messages.PbPutKvRespForBucket;
 import org.apache.fluss.rpc.messages.PbRebalancePlanForBucket;
 import org.apache.fluss.rpc.messages.PbRebalanceProgressForBucket;
+import org.apache.fluss.rpc.messages.PbRemoteLogManifestEntry;
 import org.apache.fluss.rpc.messages.PbRemoteLogSegment;
 import org.apache.fluss.rpc.messages.PbRemotePathAndLocalFile;
 import org.apache.fluss.rpc.messages.PbRenameColumn;
@@ -192,6 +194,7 @@ import org.apache.fluss.server.metadata.ClusterMetadata;
 import org.apache.fluss.server.metadata.PartitionMetadata;
 import org.apache.fluss.server.metadata.ServerInfo;
 import org.apache.fluss.server.metadata.TableMetadata;
+import org.apache.fluss.server.zk.ZooKeeperClient.TableBucketAndManifest;
 import org.apache.fluss.server.zk.data.BucketSnapshot;
 import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.server.zk.data.PartitionRegistration;
@@ -2252,4 +2255,22 @@ public class ServerRpcMessageUtils {
         }
         return offsets;
     }
+
+    public static ListRemoteLogManifestsResponse 
makeListRemoteLogManifestsResponse(
+            List<TableBucketAndManifest> remoteLogManifestInfos) {
+        ListRemoteLogManifestsResponse response = new 
ListRemoteLogManifestsResponse();
+        for (TableBucketAndManifest entry : remoteLogManifestInfos) {
+            PbRemoteLogManifestEntry pb = response.addManifest();
+            PbTableBucket pbTb = pb.setTableBucket();
+            pbTb.setTableId(entry.getTableBucket().getTableId());
+            if (entry.getTableBucket().getPartitionId() != null) {
+                pbTb.setPartitionId(entry.getTableBucket().getPartitionId());
+            }
+            pbTb.setBucketId(entry.getTableBucket().getBucket());
+            pb.setRemoteLogManifestPath(
+                    
entry.getManifestHandle().getRemoteLogManifestPath().toString());
+            
pb.setRemoteLogEndOffset(entry.getManifestHandle().getRemoteLogEndOffset());
+        }
+        return response;
+    }
 }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java 
b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java
index cb34a2f2e..533cca6ff 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java
@@ -1341,6 +1341,71 @@ public class ZooKeeperClient implements AutoCloseable {
         return getOrEmpty(path).map(BucketRemoteLogsZNode::decode);
     }
 
+    /**
+     * Lists all remote log manifest handles for buckets of the given table 
(or partition when
+     * {@code partitionId != null}). Reads the {@code BucketRemoteLogsZNode} 
subtree under either
+     * {@code /tabletservers/tables/{tableId}/buckets/} or {@code
+     * /tabletservers/partitions/{partitionId}/buckets/} as a single ZK 
getChildren call followed by
+     * per-bucket getData. Returns each bucket's currently-published manifest 
path from coordinator
+     * metadata; callers must read the manifest file separately.
+     */
+    public List<TableBucketAndManifest> listRemoteLogManifestHandles(
+            long tableId, @Nullable Long partitionId) throws Exception {
+        String bucketsPath =
+                partitionId == null
+                        ? TableIdZNode.path(tableId) + "/buckets"
+                        : PartitionIdZNode.path(partitionId) + "/buckets";
+        List<String> bucketIdStrs = getChildren(bucketsPath);
+        List<TableBucketAndManifest> result = new 
ArrayList<>(bucketIdStrs.size());
+        for (String bucketIdStr : bucketIdStrs) {
+            int bucketId = Integer.parseInt(bucketIdStr);
+            TableBucket tb =
+                    partitionId == null
+                            ? new TableBucket(tableId, bucketId)
+                            : new TableBucket(tableId, partitionId, bucketId);
+            Optional<RemoteLogManifestHandle> handle = 
getRemoteLogManifestHandle(tb);
+            handle.ifPresent(h -> result.add(new TableBucketAndManifest(tb, 
h)));
+        }
+        return result;
+    }
+
+    /** Tuple of a table bucket and its current remote log manifest handle. */
+    public static final class TableBucketAndManifest {
+        private final TableBucket tableBucket;
+        private final RemoteLogManifestHandle manifestHandle;
+
+        public TableBucketAndManifest(
+                TableBucket tableBucket, RemoteLogManifestHandle 
manifestHandle) {
+            this.tableBucket = tableBucket;
+            this.manifestHandle = manifestHandle;
+        }
+
+        public TableBucket getTableBucket() {
+            return tableBucket;
+        }
+
+        public RemoteLogManifestHandle getManifestHandle() {
+            return manifestHandle;
+        }
+    }
+
+    /**
+     * Lists the snapshot ids of all completed bucket snapshots for the given 
{@link TableBucket}.
+     * Reads only the {@code BucketSnapshotsZNode} children — the per-snapshot 
payload is not
+     * fetched, since callers (e.g. orphan-files cleanup) only need the id set 
to identify which
+     * snapshot directories must be retained. Returned ids are ordered 
ascending.
+     */
+    public List<Long> listBucketSnapshotIds(TableBucket tableBucket) throws 
Exception {
+        String path = BucketSnapshotsZNode.path(tableBucket);
+        List<String> snapshotIdStrs = getChildren(path);
+        List<Long> ids = new ArrayList<>(snapshotIdStrs.size());
+        for (String snapshotIdStr : snapshotIdStrs) {
+            ids.add(Long.parseLong(snapshotIdStr));
+        }
+        Collections.sort(ids);
+        return ids;
+    }
+
     /** Upsert the {@link LakeTable} to Zk Node. */
     public void upsertLakeTable(long tableId, LakeTable lakeTable, boolean 
isUpdate)
             throws Exception {
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorServiceOrphanRpcsITCase.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorServiceOrphanRpcsITCase.java
new file mode 100644
index 000000000..d5bb63015
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorServiceOrphanRpcsITCase.java
@@ -0,0 +1,259 @@
+/*
+ * 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.fluss.server.coordinator;
+
+import org.apache.fluss.fs.FsPath;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.rpc.gateway.CoordinatorGateway;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsRequest;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsRequest;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
+import org.apache.fluss.rpc.messages.PbKvSnapshot;
+import org.apache.fluss.rpc.messages.PbRemoteLogManifestEntry;
+import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
+import org.apache.fluss.server.kv.snapshot.CompletedSnapshotHandle;
+import org.apache.fluss.server.kv.snapshot.CompletedSnapshotHandleStore;
+import org.apache.fluss.server.kv.snapshot.CompletedSnapshotStore;
+import org.apache.fluss.server.kv.snapshot.KvSnapshotHandle;
+import org.apache.fluss.server.kv.snapshot.SharedKvFileRegistry;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+import org.apache.fluss.server.testutils.RpcMessageTestUtils;
+import org.apache.fluss.server.zk.ZooKeeperClient;
+import org.apache.fluss.server.zk.data.RemoteLogManifestHandle;
+import org.apache.fluss.types.DataTypes;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import static 
org.apache.fluss.shaded.guava32.com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** ITCase for orphan-cleanup-related coordinator RPCs: 
ListRemoteLogManifests, ListKvSnapshots. */
+class CoordinatorServiceOrphanRpcsITCase {
+
+    @RegisterExtension
+    public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION =
+            FlussClusterExtension.builder().setNumOfTabletServers(1).build();
+
+    private static final TablePath PK_TABLE_PATH = 
TablePath.of("orphan_rpc_test_db", "pk_table");
+    private static final TableDescriptor PK_TABLE_DESCRIPTOR =
+            TableDescriptor.builder()
+                    .schema(
+                            Schema.newBuilder()
+                                    .column("id", DataTypes.INT())
+                                    .withComment("pk col")
+                                    .column("val", DataTypes.STRING())
+                                    .withComment("value col")
+                                    .primaryKey("id")
+                                    .build())
+                    .distributedBy(3, "id")
+                    .build();
+
+    private static final int MAX_SNAPSHOTS_TO_RETAIN = 2;
+
+    private static long pkTableId;
+    @TempDir static Path tempDir;
+
+    @BeforeAll
+    static void setupTables() throws Exception {
+        pkTableId =
+                RpcMessageTestUtils.createTable(
+                        FLUSS_CLUSTER_EXTENSION, PK_TABLE_PATH, 
PK_TABLE_DESCRIPTOR);
+        FLUSS_CLUSTER_EXTENSION.waitUntilTableReady(pkTableId);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // ListRemoteLogManifests
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void listManifestsForUnpartitionedTable() throws Exception {
+        long tableId = 999_111L;
+        ZooKeeperClient zk = FLUSS_CLUSTER_EXTENSION.getZooKeeperClient();
+        zk.upsertRemoteLogManifestHandle(
+                new TableBucket(tableId, 0),
+                new RemoteLogManifestHandle(new 
FsPath("oss://bucket-0/m.manifest"), 100L));
+        zk.upsertRemoteLogManifestHandle(
+                new TableBucket(tableId, 1),
+                new RemoteLogManifestHandle(new 
FsPath("oss://bucket-1/m.manifest"), 200L));
+
+        CoordinatorGateway gateway = 
FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+        ListRemoteLogManifestsRequest request = new 
ListRemoteLogManifestsRequest();
+        request.setTableId(tableId);
+        ListRemoteLogManifestsResponse response = 
gateway.listRemoteLogManifests(request).get();
+
+        assertThat(response.getManifestsList()).hasSize(2);
+        assertThat(response.getManifestsList())
+                .extracting(PbRemoteLogManifestEntry::getRemoteLogEndOffset)
+                .containsExactlyInAnyOrder(100L, 200L);
+    }
+
+    @Test
+    void listManifestsForUnknownTableReturnsEmpty() throws Exception {
+        CoordinatorGateway gateway = 
FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+        ListRemoteLogManifestsRequest request = new 
ListRemoteLogManifestsRequest();
+        request.setTableId(999_999L);
+        ListRemoteLogManifestsResponse response = 
gateway.listRemoteLogManifests(request).get();
+
+        assertThat(response.getManifestsList()).isEmpty();
+    }
+
+    // 
-------------------------------------------------------------------------
+    // ListKvSnapshots
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void listSnapshotsReturnsRetainedSubset() throws Exception {
+        TableBucket tb = new TableBucket(pkTableId, 0);
+        CompletedSnapshotStore store = createTestStore(snapshot -> false);
+        for (long snapId = 1L; snapId <= 4L; snapId++) {
+            store.add(createMinimalSnapshot(tb, snapId));
+        }
+        injectStore(tb, store);
+
+        CoordinatorGateway gateway = 
FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+        ListKvSnapshotsRequest request = new ListKvSnapshotsRequest();
+        request.setTableId(pkTableId);
+        ListKvSnapshotsResponse response = 
gateway.listKvSnapshots(request).get();
+
+        assertThat(response.getTableId()).isEqualTo(pkTableId);
+        assertThat(response.hasPartitionId()).isFalse();
+        List<PbKvSnapshot> bucket0 =
+                response.getActiveSnapshotsList().stream()
+                        .filter(s -> s.getBucketId() == 0)
+                        .collect(Collectors.toList());
+        assertThat(bucket0)
+                .extracting(PbKvSnapshot::getSnapshotId)
+                .containsExactlyInAnyOrder(3L, 4L);
+    }
+
+    @Test
+    void listSnapshotsReturnsStillInUseBeyondRetention() throws Exception {
+        TableBucket tb = new TableBucket(pkTableId, 1);
+        CompletedSnapshotStore store = createTestStore(snapshot -> 
snapshot.getSnapshotID() == 1L);
+        for (long snapId = 1L; snapId <= 4L; snapId++) {
+            store.add(createMinimalSnapshot(tb, snapId));
+        }
+        injectStore(tb, store);
+
+        CoordinatorGateway gateway = 
FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+        ListKvSnapshotsRequest request = new ListKvSnapshotsRequest();
+        request.setTableId(pkTableId);
+        ListKvSnapshotsResponse response = 
gateway.listKvSnapshots(request).get();
+
+        List<PbKvSnapshot> bucket1Snapshots =
+                response.getActiveSnapshotsList().stream()
+                        .filter(s -> s.getBucketId() == 1)
+                        .collect(Collectors.toList());
+        // RETAINED = {3, 4}, STILL_IN_USE = {1}
+        assertThat(bucket1Snapshots)
+                .extracting(PbKvSnapshot::getSnapshotId)
+                .containsExactlyInAnyOrder(1L, 3L, 4L);
+    }
+
+    @Test
+    void listSnapshotsForUnknownTableFails() {
+        CoordinatorGateway gateway = 
FLUSS_CLUSTER_EXTENSION.newCoordinatorClient();
+        ListKvSnapshotsRequest request = new ListKvSnapshotsRequest();
+        request.setTableId(999_888L);
+
+        assertThatThrownBy(() -> gateway.listKvSnapshots(request).get())
+                .isInstanceOf(ExecutionException.class);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Helpers
+    // 
-------------------------------------------------------------------------
+
+    private CompletedSnapshotStore createTestStore(
+            CompletedSnapshotStore.SnapshotInUseChecker inUseChecker) {
+        return new CompletedSnapshotStore(
+                MAX_SNAPSHOTS_TO_RETAIN,
+                new SharedKvFileRegistry(),
+                Collections.emptyList(),
+                new NoOpSnapshotHandleStore(),
+                directExecutor(),
+                inUseChecker);
+    }
+
+    private void injectStore(TableBucket tb, CompletedSnapshotStore store) {
+        CompletedSnapshotStoreManager storeManager =
+                FLUSS_CLUSTER_EXTENSION
+                        .getCoordinatorServer()
+                        .getCoordinatorEventProcessor()
+                        .completedSnapshotStoreManager();
+        storeManager.getBucketCompletedSnapshotStores().put(tb, store);
+    }
+
+    private CompletedSnapshot createMinimalSnapshot(TableBucket tb, long 
snapshotId) {
+        Path snapDir = tempDir.resolve("snap-" + tb.getBucket() + "-" + 
snapshotId);
+        snapDir.toFile().mkdirs();
+        return new CompletedSnapshot(
+                tb,
+                snapshotId,
+                new FsPath(snapDir.resolve("_metadata").toUri().toString()),
+                new KvSnapshotHandle(Collections.emptyList(), 
Collections.emptyList(), 0L),
+                snapshotId,
+                null,
+                null);
+    }
+
+    /** No-op implementation that avoids ZK writes during test snapshot 
population. */
+    private static class NoOpSnapshotHandleStore implements 
CompletedSnapshotHandleStore {
+
+        @Override
+        public void add(
+                TableBucket tableBucket,
+                long snapshotId,
+                CompletedSnapshotHandle completedSnapshotHandle) {}
+
+        @Override
+        public void remove(TableBucket tableBucket, long snapshotId) {}
+
+        @Override
+        public Optional<CompletedSnapshotHandle> get(TableBucket tableBucket, 
long snapshotId) {
+            return Optional.empty();
+        }
+
+        @Override
+        public List<CompletedSnapshotHandle> getAllCompletedSnapshotHandles(
+                TableBucket tableBucket) {
+            return Collections.emptyList();
+        }
+
+        @Override
+        public Optional<CompletedSnapshotHandle> 
getLatestCompletedSnapshotHandle(
+                TableBucket tableBucket) {
+            return Optional.empty();
+        }
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
index 8530ad4e7..7f3bc32e8 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
@@ -94,10 +94,14 @@ import org.apache.fluss.rpc.messages.ListAclsRequest;
 import org.apache.fluss.rpc.messages.ListAclsResponse;
 import org.apache.fluss.rpc.messages.ListDatabasesRequest;
 import org.apache.fluss.rpc.messages.ListDatabasesResponse;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsRequest;
+import org.apache.fluss.rpc.messages.ListKvSnapshotsResponse;
 import org.apache.fluss.rpc.messages.ListPartitionInfosRequest;
 import org.apache.fluss.rpc.messages.ListPartitionInfosResponse;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressRequest;
 import org.apache.fluss.rpc.messages.ListRebalanceProgressResponse;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsRequest;
+import org.apache.fluss.rpc.messages.ListRemoteLogManifestsResponse;
 import org.apache.fluss.rpc.messages.ListTablesRequest;
 import org.apache.fluss.rpc.messages.ListTablesResponse;
 import org.apache.fluss.rpc.messages.MetadataRequest;
@@ -267,6 +271,18 @@ public class TestCoordinatorGateway implements 
CoordinatorGateway {
         return null;
     }
 
+    @Override
+    public CompletableFuture<ListRemoteLogManifestsResponse> 
listRemoteLogManifests(
+            ListRemoteLogManifestsRequest request) {
+        return null;
+    }
+
+    @Override
+    public CompletableFuture<ListKvSnapshotsResponse> listKvSnapshots(
+            ListKvSnapshotsRequest request) {
+        return null;
+    }
+
     @Override
     public CompletableFuture<GetLakeSnapshotResponse> getLakeSnapshot(
             GetLakeSnapshotRequest request) {

Reply via email to