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

swuferhong 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 02e40ec17 fix: remote orphan files and directories when tablet starts 
up (#3388)
02e40ec17 is described below

commit 02e40ec17e46284ec1a952ed92c001c7a3d5b070
Author: Yang Guo <[email protected]>
AuthorDate: Sun Jun 7 20:15:49 2026 +0800

    fix: remote orphan files and directories when tablet starts up (#3388)
---
 .../org/apache/fluss/server/log/LogManager.java    |  62 ++++++++-
 .../fluss/server/replica/ReplicaManager.java       |  91 ++++++++++++--
 .../server/coordinator/StopReplicaITCase.java      | 138 +++++++++++++++++++++
 .../fluss/server/replica/ReplicaManagerTest.java   |  40 ++++++
 4 files changed, 316 insertions(+), 15 deletions(-)

diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/log/LogManager.java 
b/fluss-server/src/main/java/org/apache/fluss/server/log/LogManager.java
index ae0432820..3c804596a 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/log/LogManager.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/log/LogManager.java
@@ -23,6 +23,7 @@ import org.apache.fluss.config.Configuration;
 import org.apache.fluss.exception.FlussException;
 import org.apache.fluss.exception.FlussRuntimeException;
 import org.apache.fluss.exception.LogStorageException;
+import org.apache.fluss.exception.PartitionNotExistException;
 import org.apache.fluss.exception.SchemaNotExistException;
 import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.PhysicalTablePath;
@@ -34,6 +35,7 @@ import 
org.apache.fluss.server.log.checkpoint.OffsetCheckpointFile;
 import org.apache.fluss.server.metrics.group.TabletServerMetricGroup;
 import org.apache.fluss.server.storage.LocalDiskManager;
 import org.apache.fluss.server.zk.ZooKeeperClient;
+import org.apache.fluss.server.zk.data.PartitionRegistration;
 import org.apache.fluss.utils.ExceptionUtils;
 import org.apache.fluss.utils.FileUtils;
 import org.apache.fluss.utils.FlussPaths;
@@ -48,7 +50,9 @@ import javax.annotation.concurrent.ThreadSafe;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.DirectoryNotEmptyException;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -386,6 +390,25 @@ public final class LogManager extends TabletManagerBase {
         PhysicalTablePath physicalTablePath = pathAndBucket.f0;
         TablePath tablePath = physicalTablePath.getTablePath();
         TableInfo tableInfo = getTableInfo(zkClient, tablePath);
+
+        // Table schema exists, but the partition may have been dropped (or 
dropped
+        // and recreated with a new ID) while this TS was offline.
+        // Validate both partition name and partition ID against ZK.
+        String partitionName = physicalTablePath.getPartitionName();
+        if (partitionName != null) {
+            Optional<PartitionRegistration> registration =
+                    zkClient.getPartition(tablePath, partitionName);
+            if (!registration.isPresent()
+                    || registration.get().getPartitionId() != 
tableBucket.getPartitionId()) {
+                throw new PartitionNotExistException(
+                        String.format(
+                                "Failed to load partition '%s' 
(partitionId=%d) of table '%s': "
+                                        + "partition not found or partitionId 
mismatch in "
+                                        + "zookeeper metadata.",
+                                partitionName, tableBucket.getPartitionId(), 
tablePath));
+            }
+        }
+
         LogTablet logTablet =
                 LogTablet.create(
                         dataDir,
@@ -535,17 +558,19 @@ public final class LogManager extends TabletManagerBase {
                     loadLog(dataDir, tabletDir, cleanShutdown, recoveryPoints, 
conf, clock);
                 } catch (Exception e) {
                     LOG.error("Fail to loadLog from {}", tabletDir, e);
-                    if (e instanceof SchemaNotExistException) {
+                    if (e instanceof SchemaNotExistException
+                            || e instanceof PartitionNotExistException) {
                         LOG.error(
-                                "schema not exist, table for {} has already 
been dropped, the residual data will be removed.",
+                                "Table or partition for {} has already been 
dropped, the residual data will be removed.",
                                 tabletDir,
                                 e);
                         FileUtils.deleteDirectoryQuietly(tabletDir);
 
-                        // Also delete corresponding KV tablet directory if it 
exists
                         try {
                             Tuple2<PhysicalTablePath, TableBucket> 
pathAndBucket =
                                     FlussPaths.parseTabletDir(tabletDir);
+
+                            // Also delete corresponding KV tablet directory 
if it exists
                             File kvTabletDir =
                                     FlussPaths.kvTabletDir(
                                             dataDir, pathAndBucket.f0, 
pathAndBucket.f1);
@@ -555,11 +580,24 @@ public final class LogManager extends TabletManagerBase {
                                         kvTabletDir);
                                 FileUtils.deleteDirectoryQuietly(kvTabletDir);
                             }
-                        } catch (Exception kvDeleteException) {
+
+                            boolean isPartitioned = 
pathAndBucket.f0.getPartitionName() != null;
+                            File partitionDir = tabletDir.getParentFile();
+                            if (partitionDir != null) {
+                                deleteEmptyDirQuietly(partitionDir);
+
+                                if (isPartitioned) {
+                                    File tableDir = 
partitionDir.getParentFile();
+                                    if (tableDir != null) {
+                                        deleteEmptyDirQuietly(tableDir);
+                                    }
+                                }
+                            }
+                        } catch (Exception cleanupException) {
                             LOG.warn(
-                                    "Failed to delete corresponding KV tablet 
directory for log {}: {}",
+                                    "Failed to clean up residual KV/parent 
directories for {}: {}",
                                     tabletDir,
-                                    kvDeleteException.getMessage());
+                                    cleanupException.getMessage());
                         }
                         return;
                     }
@@ -607,6 +645,18 @@ public final class LogManager extends TabletManagerBase {
         }
     }
 
+    private static void deleteEmptyDirQuietly(File dir) {
+        try {
+            Files.delete(dir.toPath());
+        } catch (DirectoryNotEmptyException e) {
+            LOG.warn("Directory {} is not empty, skipping deletion.", dir);
+        } catch (NoSuchFileException ignored) {
+            // Already gone — fine.
+        } catch (IOException e) {
+            LOG.warn("Failed to delete empty directory {}: {}", dir, 
e.getMessage());
+        }
+    }
+
     private static final class LogShutdownTask {
         private final File dataDir;
         private final List<LogTablet> logs;
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
index 0aaea033a..280e34124 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
@@ -27,6 +27,7 @@ import org.apache.fluss.exception.FencedLeaderEpochException;
 import org.apache.fluss.exception.InvalidColumnProjectionException;
 import org.apache.fluss.exception.InvalidCoordinatorException;
 import org.apache.fluss.exception.InvalidRequiredAcksException;
+import org.apache.fluss.exception.KvStorageException;
 import org.apache.fluss.exception.LogOffsetOutOfRangeException;
 import org.apache.fluss.exception.LogStorageException;
 import org.apache.fluss.exception.NotLeaderOrFollowerException;
@@ -124,7 +125,9 @@ import javax.annotation.concurrent.GuardedBy;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.DirectoryNotEmptyException;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -145,7 +148,6 @@ import java.util.stream.Stream;
 
 import static org.apache.fluss.config.ConfigOptions.KV_FORMAT_VERSION_2;
 import static org.apache.fluss.server.TabletManagerBase.getTableInfo;
-import static org.apache.fluss.utils.FileUtils.isDirectoryEmpty;
 import static org.apache.fluss.utils.Preconditions.checkArgument;
 import static org.apache.fluss.utils.Preconditions.checkNotNull;
 import static org.apache.fluss.utils.Preconditions.checkState;
@@ -978,7 +980,20 @@ public class ReplicaManager implements 
ServerReconfigurable {
                         TableBucket tb = data.getTableBucket();
                         HostedReplica hostedReplica = getReplica(tb);
                         if (hostedReplica instanceof NoneReplica) {
-                            // do nothing fort this case.
+                            if (data.isDeleteLocal()) {
+                                try {
+                                    sweepOrphanTabletDirs(tb, deletedTableIds, 
deletedPartitionIds);
+                                } catch (Exception e) {
+                                    LOG.error(
+                                            "Failed to sweep orphan tablet 
directories for {}",
+                                            tb,
+                                            e);
+                                    result.add(
+                                            new StopReplicaResultForBucket(
+                                                    tb, 
ApiError.fromThrowable(e)));
+                                    continue;
+                                }
+                            }
                             result.add(new StopReplicaResultForBucket(tb));
                         } else if (hostedReplica instanceof OfflineReplica) {
                             LOG.warn(
@@ -1927,6 +1942,59 @@ public class ReplicaManager implements 
ServerReconfigurable {
         return new StopReplicaResultForBucket(tb);
     }
 
+    /**
+     * Remove on-disk tablet directories for a bucket that the in-memory 
ReplicaManager does not
+     * know about. This handles the case where a stopReplica(delete=true) 
arrives after the
+     * TabletServer was restarted during a delete — LogManager loaded the log 
at startup but no
+     * NotifyLeaderAndIsr ever ran, so allReplicas is empty.
+     */
+    private void sweepOrphanTabletDirs(
+            TableBucket tb, Map<Long, Path> deletedTableIds, Map<Long, Path> 
deletedPartitionIds) {
+        Optional<LogTablet> orphanLog = logManager.getLog(tb);
+        if (!orphanLog.isPresent()) {
+            return;
+        }
+
+        LogTablet logTablet = orphanLog.get();
+        File dataDir = logTablet.getDataDir();
+        PhysicalTablePath physicalTablePath = logTablet.getPhysicalTablePath();
+        Path tabletParentDir = logManager.getTabletParentDir(dataDir, 
physicalTablePath, tb);
+
+        // Clean KV before log so that if KV cleanup fails, the log is still
+        // present and a coordinator retry can re-enter this method.
+        boolean isKvTable = false;
+        if (kvManager.getKv(tb).isPresent()) {
+            kvManager.dropKv(tb);
+            isKvTable = true;
+        } else {
+            File kvTabletDir = FlussPaths.kvTabletDir(dataDir, 
physicalTablePath, tb);
+            if (kvTabletDir.exists()) {
+                isKvTable = true;
+                try {
+                    FileUtils.deleteDirectory(kvTabletDir);
+                } catch (IOException e) {
+                    throw new KvStorageException(
+                            String.format(
+                                    "Failed to delete orphan KV tablet 
directory %s", kvTabletDir),
+                            e);
+                }
+            }
+        }
+
+        logManager.dropLog(tb);
+
+        localDiskManager.recordReplicaDelete(dataDir, isKvTable);
+
+        if (tb.getPartitionId() != null) {
+            deletedPartitionIds.put(tb.getPartitionId(), tabletParentDir);
+            deletedTableIds.put(tb.getTableId(), tabletParentDir.getParent());
+        } else {
+            deletedTableIds.put(tb.getTableId(), tabletParentDir);
+        }
+
+        LOG.info("Swept orphan tablet directories for bucket {}", tb);
+    }
+
     private void truncateToHighWatermark(List<Replica> replicas) {
         for (Replica replica : replicas) {
             long highWatermark = replica.getLogTablet().getHighWatermark();
@@ -1960,14 +2028,19 @@ public class ReplicaManager implements 
ServerReconfigurable {
     }
 
     private void dropEmptyTableOrPartitionDir(Path dir, long id, String 
dirType) {
-        if (!Files.exists(dir) || !isDirectoryEmpty(dir)) {
-            return;
-        }
-
-        LOG.info("Drop empty {} dir '{}' of {} id {}.", dirType, dir, dirType, 
id);
         try {
-            FileUtils.deleteDirectory(dir.toFile());
-        } catch (Exception e) {
+            Files.delete(dir);
+            LOG.info("Dropped empty {} dir '{}' of {} id {}.", dirType, dir, 
dirType, id);
+        } catch (DirectoryNotEmptyException e) {
+            LOG.warn(
+                    "{} dir '{}' of {} id {} is not empty, skipping deletion.",
+                    dirType,
+                    dir,
+                    dirType,
+                    id);
+        } catch (NoSuchFileException ignored) {
+            // Already gone — fine.
+        } catch (IOException e) {
             LOG.error("Failed to delete empty {} dir '{}' of {} id {}.", 
dirType, dir, dirType, e);
         }
     }
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/StopReplicaITCase.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/StopReplicaITCase.java
index 2abec7264..86e2fb8f5 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/StopReplicaITCase.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/StopReplicaITCase.java
@@ -19,31 +19,42 @@ package org.apache.fluss.server.coordinator;
 
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.PartitionSpec;
+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.record.MemoryLogRecords;
 import org.apache.fluss.rpc.gateway.CoordinatorGateway;
+import org.apache.fluss.rpc.gateway.TabletServerGateway;
 import org.apache.fluss.server.replica.Replica;
 import org.apache.fluss.server.replica.ReplicaManager;
 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.LeaderAndIsr;
+import org.apache.fluss.types.DataTypes;
 
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.File;
 import java.nio.file.Path;
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
+import static org.apache.fluss.record.TestData.DATA1;
 import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR;
 import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR_PK;
 import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
 import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH_PK;
+import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.newProduceLogRequest;
+import static 
org.apache.fluss.testutils.DataTestUtils.genMemoryLogRecordsByObject;
 import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
 import static org.apache.fluss.testutils.common.CommonTestUtils.waitValue;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -132,6 +143,133 @@ public class StopReplicaITCase {
         retryUtilReplicaNotExist(tb, isr2, tableDirs2);
     }
 
+    @Test
+    void testDropTableCleansOrphanDirsOnTabletServerRestart() throws Exception 
{
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+
+        TablePath tablePath = TablePath.of("test_db_stop_replica", 
"test_orphan_table");
+        long tableId =
+                RpcMessageTestUtils.createTable(
+                        FLUSS_CLUSTER_EXTENSION, tablePath, 
DATA1_TABLE_DESCRIPTOR);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+
+        List<Integer> isr = waitAndGetIsr(tb);
+
+        // Write data so that actual segment files exist on every replica.
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateway =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+        MemoryLogRecords records = genMemoryLogRecordsByObject(DATA1);
+        leaderGateway.produceLog(newProduceLogRequest(tableId, 0, 1, 
records)).get();
+
+        int offlineServerId = isr.get(0);
+        ReplicaManager replicaManager =
+                
FLUSS_CLUSTER_EXTENSION.getTabletServerById(offlineServerId).getReplicaManager();
+        Replica replica = replicaManager.getReplicaOrException(tb);
+        Path offlineTsTableDir = replica.getTabletParentDir();
+        File offlineTsLogDir = replica.getLogTablet().getLogDir();
+        assertThat(offlineTsTableDir).exists();
+        assertThat(offlineTsLogDir).exists();
+        // Verify actual data files (.log, .index) exist under the log 
directory.
+        assertThat(offlineTsLogDir.listFiles()).isNotEmpty();
+
+        FLUSS_CLUSTER_EXTENSION.stopTabletServer(offlineServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(2);
+
+        coordinatorGateway
+                .dropTable(
+                        RpcMessageTestUtils.newDropTableRequest(
+                                tablePath.getDatabaseName(), 
tablePath.getTableName(), false))
+                .get();
+        assertThat(zkClient.tableExist(tablePath)).isFalse();
+
+        FLUSS_CLUSTER_EXTENSION.startTabletServer(offlineServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(3);
+
+        // Both the log directory (with data files) and the table parent 
directory
+        // should be cleaned at startup via the SchemaNotExistException 
handler.
+        retry(
+                Duration.ofMinutes(1),
+                () -> {
+                    assertThat(offlineTsLogDir).doesNotExist();
+                    assertThat(offlineTsTableDir).doesNotExist();
+                });
+    }
+
+    @Test
+    void testDropPartitionCleansOrphanDirsOnTabletServerRestart() throws 
Exception {
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+
+        TablePath tablePath = TablePath.of("test_db_stop_replica", 
"test_orphan_partition");
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(
+                                Schema.newBuilder()
+                                        .column("a", DataTypes.INT())
+                                        .column("b", DataTypes.STRING())
+                                        .build())
+                        .distributedBy(1)
+                        .partitionedBy("b")
+                        .property(ConfigOptions.TABLE_REPLICATION_FACTOR, 3)
+                        .build();
+        long tableId =
+                RpcMessageTestUtils.createTable(
+                        FLUSS_CLUSTER_EXTENSION, tablePath, tableDescriptor);
+
+        String partitionName = "p1";
+        long partitionId =
+                RpcMessageTestUtils.createPartition(
+                        FLUSS_CLUSTER_EXTENSION,
+                        tablePath,
+                        new PartitionSpec(Collections.singletonMap("b", 
partitionName)),
+                        false);
+        TableBucket tb = new TableBucket(tableId, partitionId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+
+        List<Integer> isr = waitAndGetIsr(tb);
+
+        // Write data so that actual segment files exist on every replica.
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateway =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+        MemoryLogRecords records = genMemoryLogRecordsByObject(DATA1);
+        leaderGateway.produceLog(newProduceLogRequest(tableId, 0, 1, 
records)).get();
+
+        int offlineServerId = isr.get(0);
+        ReplicaManager replicaManager =
+                
FLUSS_CLUSTER_EXTENSION.getTabletServerById(offlineServerId).getReplicaManager();
+        Replica replica = replicaManager.getReplicaOrException(tb);
+        Path offlineTsPartitionDir = replica.getTabletParentDir();
+        File offlineTsLogDir = replica.getLogTablet().getLogDir();
+        assertThat(offlineTsPartitionDir).exists();
+        assertThat(offlineTsLogDir).exists();
+        assertThat(offlineTsLogDir.listFiles()).isNotEmpty();
+
+        FLUSS_CLUSTER_EXTENSION.stopTabletServer(offlineServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(2);
+
+        coordinatorGateway
+                .dropPartition(
+                        RpcMessageTestUtils.newDropPartitionRequest(
+                                tablePath,
+                                new 
PartitionSpec(Collections.singletonMap("b", partitionName)),
+                                false))
+                .get();
+
+        FLUSS_CLUSTER_EXTENSION.startTabletServer(offlineServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(3);
+
+        // Both the log directory (with data files) and the partition parent
+        // directory should be cleaned at startup via the 
PartitionNotExistException handler.
+        retry(
+                Duration.ofMinutes(1),
+                () -> {
+                    assertThat(offlineTsLogDir).doesNotExist();
+                    assertThat(offlineTsPartitionDir).doesNotExist();
+                });
+    }
+
     private List<Integer> waitAndGetIsr(TableBucket tb) {
         LeaderAndIsr leaderAndIsr =
                 waitValue(
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
index eea0d5eb1..ff6b0246e 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
@@ -28,6 +28,7 @@ import 
org.apache.fluss.exception.NotLeaderOrFollowerException;
 import org.apache.fluss.exception.UnknownTableOrBucketException;
 import org.apache.fluss.metadata.DataLakeFormat;
 import org.apache.fluss.metadata.KvFormat;
+import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.PhysicalTablePath;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.SchemaGetter;
@@ -69,6 +70,7 @@ import org.apache.fluss.server.kv.rocksdb.RocksDBKv;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import org.apache.fluss.server.log.FetchParams;
 import org.apache.fluss.server.log.ListOffsetsParam;
+import org.apache.fluss.server.log.LogTablet;
 import org.apache.fluss.server.log.checkpoint.OffsetCheckpointFile;
 import org.apache.fluss.server.metadata.BucketMetadata;
 import org.apache.fluss.server.metadata.ClusterMetadata;
@@ -96,6 +98,7 @@ import javax.annotation.Nullable;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -2545,4 +2548,41 @@ class ReplicaManagerTest extends ReplicaTestBase {
         assertThat(checkpoint2).containsOnlyKeys(new 
TableBucket(DATA1_TABLE_ID, 1));
         assertThat(checkpoint2.get(new TableBucket(DATA1_TABLE_ID, 
1))).isEqualTo(10L);
     }
+
+    @Test
+    void testStopReplicaSweepsOrphanDirsForNoneReplica() throws Exception {
+        TableBucket tb = new TableBucket(DATA1_TABLE_ID, 0);
+        PhysicalTablePath physicalTablePath = 
PhysicalTablePath.of(DATA1_TABLE_PATH);
+
+        // Create a log directly via LogManager without going through 
ReplicaManager.
+        // This simulates the state after TS restart where LogManager loaded 
the log
+        // but no NotifyLeaderAndIsr arrived (so allReplicas is empty → 
NoneReplica).
+        File dataDir = localDiskManager.dataDirs().get(0);
+        LogTablet logTablet =
+                logManager.getOrCreateLog(
+                        dataDir, physicalTablePath, tb, LogFormat.ARROW, 1, 
false);
+        File logDir = logTablet.getLogDir();
+        Path tableDir = logManager.getTabletParentDir(dataDir, 
physicalTablePath, tb);
+        assertThat(logDir).exists();
+        assertThat(tableDir).exists();
+
+        // Verify the bucket is NoneReplica (not in allReplicas).
+        
assertThat(replicaManager.getReplica(tb)).isInstanceOf(ReplicaManager.NoneReplica.class);
+
+        // Send stopReplicas with deleteLocal=true. This should hit the 
NoneReplica
+        // branch and invoke sweepOrphanTabletDirs to clean up the orphan log.
+        CompletableFuture<List<StopReplicaResultForBucket>> future = new 
CompletableFuture<>();
+        replicaManager.stopReplicas(
+                INITIAL_COORDINATOR_EPOCH,
+                Collections.singletonList(
+                        new StopReplicaData(tb, true, false, 
INITIAL_COORDINATOR_EPOCH, 0)),
+                future::complete);
+
+        assertThat(future.get()).containsOnly(new 
StopReplicaResultForBucket(tb));
+        // The log directory and table parent directory should be cleaned up.
+        assertThat(logDir).doesNotExist();
+        assertThat(tableDir).doesNotExist();
+        // LogManager should no longer hold the log.
+        assertThat(logManager.getLog(tb)).isNotPresent();
+    }
 }

Reply via email to