This is an automated email from the ASF dual-hosted git repository.
loserwang1024 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 e176dbb69 [client] cancel pending log download when closing remote log
downloader. (#2786)
e176dbb69 is described below
commit e176dbb69f1094ad97c18ee2c2574c454c4686a2
Author: Hongshun Wang <[email protected]>
AuthorDate: Tue May 26 15:32:23 2026 +0800
[client] cancel pending log download when closing remote log downloader.
(#2786)
---
.../table/scanner/log/RemoteLogDownloader.java | 103 ++++++++++++++-------
.../table/scanner/log/RemoteLogDownloaderTest.java | 91 ++++++++++++++++++
2 files changed, 160 insertions(+), 34 deletions(-)
diff --git
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
index b30458983..15bc1f631 100644
---
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
+++
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
@@ -85,6 +85,8 @@ public class RemoteLogDownloader implements Closeable {
private final long pollTimeout;
+ private volatile boolean closed = false;
+
public RemoteLogDownloader(
TablePath tablePath,
Configuration conf,
@@ -145,6 +147,11 @@ public class RemoteLogDownloader implements Closeable {
// blocks until there is capacity (the fetched file is consumed)
prefetchSemaphore.acquire();
+ if (closed) {
+ prefetchSemaphore.release();
+ return;
+ }
+
// wait until there is a remote fetch request
RemoteLogDownloadRequest request = segmentsToFetch.poll(pollTimeout,
TimeUnit.MILLISECONDS);
if (request == null) {
@@ -163,41 +170,57 @@ public class RemoteLogDownloader implements Closeable {
long startTime = System.currentTimeMillis();
// download the remote file to local
- remoteFileDownloader
- .downloadFileAsync(fsPathAndFileName, localLogDir)
- .whenComplete(
- (bytes, throwable) -> {
- if (throwable != null) {
- LOG.error(
- "Failed to download remote log
segment file {} for table bucket {}.",
- fsPathAndFileName.getFileName(),
- tableBucket,
-
ExceptionUtils.stripExecutionException(throwable));
- // release the semaphore for the failed
request
- prefetchSemaphore.release();
- // add back the request to the queue,
- // so we do not complete the
request.future here
- segmentsToFetch.add(request);
-
scannerMetricGroup.remoteFetchErrorCount().inc();
- } else {
- LOG.info(
- "Successfully downloaded remote
log segment file {} to local for "
- + "table bucket {} cost {}
ms.",
- fsPathAndFileName.getFileName(),
- tableBucket,
- System.currentTimeMillis() -
startTime);
- File localFile =
- new File(
- localLogDir.toFile(),
-
fsPathAndFileName.getFileName());
-
scannerMetricGroup.remoteFetchBytes().inc(bytes);
- request.future.complete(localFile);
- }
- });
+ CompletableFuture<Long> completableFuture =
+ remoteFileDownloader.downloadFileAsync(fsPathAndFileName,
localLogDir);
+ completableFuture.whenComplete(
+ (bytes, throwable) -> {
+ if (closed) {
+ LOG.warn(
+ "RemoteLogDownloader closed when remote
log segment file {} for table bucket {}.",
+ fsPathAndFileName.getFileName(),
+ tableBucket);
+ // In-flight download completed after close.
Cancel the
+ // external future so consumers blocked on .get()
are
+ // unblocked, release the semaphore (no consumer
will
+ // recycle) and clean up any orphan files/dirs the
+ // download may have recreated via
Files.createDirectories
+ // — even on failure, since the directory may
already have
+ // been re-created before the error occurred.
+ request.future.cancel(false);
+ prefetchSemaphore.release();
+ deleteDirectoryQuietly(localLogDir.toFile());
+ return;
+ }
+ if (throwable != null) {
+ LOG.error(
+ "Failed to download remote log segment
file {} for table bucket {}.",
+ fsPathAndFileName.getFileName(),
+ tableBucket,
+
ExceptionUtils.stripExecutionException(throwable));
+ prefetchSemaphore.release();
+ segmentsToFetch.add(request);
+ scannerMetricGroup.remoteFetchErrorCount().inc();
+ } else {
+ LOG.info(
+ "Successfully downloaded remote log
segment file {} to local for "
+ + "table bucket {} cost {} ms.",
+ fsPathAndFileName.getFileName(),
+ tableBucket,
+ System.currentTimeMillis() - startTime);
+ File localFile =
+ new File(localLogDir.toFile(),
fsPathAndFileName.getFileName());
+ scannerMetricGroup.remoteFetchBytes().inc(bytes);
+ request.future.complete(localFile);
+ }
+ });
} catch (Throwable t) {
prefetchSemaphore.release();
- // add back the request to the queue
- segmentsToFetch.add(request);
+ // only re-queue the request if the downloader is still active
+ if (!closed) {
+ segmentsToFetch.add(request);
+ } else {
+ request.future.cancel(false);
+ }
scannerMetricGroup.remoteFetchErrorCount().inc();
// log the error and continue instead of shutdown the download
thread
LOG.error("Failed to download remote log segment for table bucket
{}.", tableBucket, t);
@@ -226,15 +249,27 @@ public class RemoteLogDownloader implements Closeable {
@Override
public void close() throws IOException {
+ closed = true;
+ // Drain pending requests and cancel their futures to unblock waiting
consumers.
+ drainAndCancelRequests();
try {
downloadThread.shutdown();
} catch (InterruptedException e) {
// ignore
}
-
+ // In-flight downloads on the shared pool may still finish and recreate
+ // localLogDir; the whenComplete callback will re-delete when it sees
closed.
deleteDirectoryQuietly(localLogDir.toFile());
}
+ /** Drains all pending requests and cancels their futures. */
+ private void drainAndCancelRequests() {
+ RemoteLogDownloadRequest request;
+ while ((request = segmentsToFetch.poll()) != null) {
+ request.future.cancel(false);
+ }
+ }
+
@VisibleForTesting
Semaphore getPrefetchSemaphore() {
return prefetchSemaphore;
diff --git
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
index 407756a7e..8984f8fc6 100644
---
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
+++
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
@@ -48,6 +48,9 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static org.apache.fluss.record.TestData.DATA1_PHYSICAL_TABLE_PATH;
@@ -282,6 +285,94 @@ class RemoteLogDownloaderTest {
assertThat(results).isEqualTo(expected);
}
+ /**
+ * Tests that close() properly cleans up the local directory even when
there are simultaneously:
+ * (1) already-downloaded files on disk, (2) in-flight downloads still
running on the shared
+ * pool, and (3) pending requests in the queue. After close() the local
directory must not exist
+ * — including after in-flight downloads finish and potentially recreate
it via
+ * Files.createDirectories.
+ */
+ @Test
+ void testCloseWithInFlightAndPendingDownloads() throws Exception {
+ // Latch to block in-flight downloads until explicitly released after
close().
+ CountDownLatch blockLatch = new CountDownLatch(1);
+ // Latch to know when 2 in-flight downloads have entered the blocked
state.
+ CountDownLatch inFlightStarted = new CountDownLatch(2);
+ // Latch to know when 2 in-flight downloads are finished.
+ CountDownLatch inFlightFinished = new CountDownLatch(2);
+
+ class BlockingFileDownloader extends RemoteFileDownloader {
+ private final AtomicInteger enteredCount = new AtomicInteger(0);
+
+ BlockingFileDownloader(int threadNum) {
+ super(threadNum);
+ }
+
+ @Override
+ protected long downloadFile(Path targetFilePath, FsPath
remoteFilePath)
+ throws IOException {
+ int count = enteredCount.incrementAndGet();
+ if (count > 1) {
+ // Block the 2nd and 3rd downloads to simulate in-flight
state.
+ inFlightStarted.countDown();
+ try {
+ blockLatch.await();
+ } catch (InterruptedException e) {
+ throw new IOException("Interrupted while blocking", e);
+ }
+ }
+ long downloadBytes = super.downloadFile(targetFilePath,
remoteFilePath);
+ inFlightFinished.countDown();
+ return downloadBytes;
+ }
+ }
+
+ // prefetch=3: downloads 0,1,2 start; downloads 3,4 remain in the
queue.
+ conf.set(ConfigOptions.CLIENT_SCANNER_REMOTE_LOG_PREFETCH_NUM, 3);
+ BlockingFileDownloader fileDownloader = new BlockingFileDownloader(4);
+ RemoteLogDownloader downloader =
+ new RemoteLogDownloader(
+ DATA1_TABLE_PATH, conf, fileDownloader,
scannerMetricGroup, 10L);
+
+ try {
+ TableBucket tb = new TableBucket(DATA1_TABLE_ID, 0);
+ List<RemoteLogSegment> segments =
+ buildRemoteLogSegmentList(tb, DATA1_PHYSICAL_TABLE_PATH,
5, conf, 10);
+ FsPath tabletDir = remoteLogTabletDir(remoteLogDir,
DATA1_PHYSICAL_TABLE_PATH, tb);
+ List<RemoteLogDownloadFuture> futures =
+ requestRemoteLogs(downloader, tabletDir, segments);
+
+ downloader.start();
+
+ // Wait for the first download to complete (already-downloaded
file on disk) and 2
+ // in-flight downloads to enter the blocked state.
+ // At this point: 1 file downloaded, 2 blocked in-flight, 2
pending.
+ retry(Duration.ofMinutes(1), () ->
assertThat(futures.get(0).isDone()).isTrue());
+ assertThat(inFlightStarted.await(30, TimeUnit.SECONDS)).isTrue();
+ Path localLogDir = downloader.getLocalLogDir();
+ assertThat(localLogDir.toFile().exists()).isTrue();
+
+ // Close the downloader.
+ downloader.close();
+
+ // Pending futures (segments 3, 4) should be cancelled immediately.
+ assertThat(futures.get(3).isDone()).isTrue();
+ assertThat(futures.get(4).isDone()).isTrue();
+
+ // Wait for 2 in-flight downloads finished.
+ blockLatch.countDown();
+ assertThat(inFlightFinished.await(30, TimeUnit.SECONDS)).isTrue();
+ retry(Duration.ofMinutes(1), () ->
assertThat(futures.get(1).isDone()).isTrue());
+
+ // Verify that ultimately the local directory does not exist.
+ retry(Duration.ofMinutes(1), () ->
assertThat(localLogDir.toFile().exists()).isFalse());
+ } finally {
+ blockLatch.countDown(); // ensure latch is released even on test
failure
+ IOUtils.closeQuietly(downloader);
+ IOUtils.closeQuietly(fileDownloader);
+ }
+ }
+
private RemoteLogDownloadRequest createDownloadRequest(
TableBucket tableBucket, long startOffset, long maxTimestamp) {
RemoteLogSegment remoteLogSegment =