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

david-streamlio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git


The following commit(s) were added to refs/heads/master by this push:
     new 6377b961 [fix][io] Fix duplicate file offers and flaky tests in file 
connector (#41)
6377b961 is described below

commit 6377b9610bb668ac6a110265bb94955743db69da
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Thu Jul 9 13:22:53 2026 -0700

    [fix][io] Fix duplicate file offers and flaky tests in file connector (#41)
    
    * [fix][io] Fix duplicate file offers and flaky tests in file connector
    
    ProcessedFileThreadTest (renameFileTest, continuousRunTest) failed
    intermittently in CI with 'offer wanted 1 time but was 2'. The root
    cause is a race in FileListingThread, not the tests: a file is briefly
    in none of the tracking queues while FileConsumerThread moves it from
    workQueue to inProcess, and again while ProcessedFileThread takes it
    from recentlyProcessed and renames/deletes it on disk. A listing pass
    in either window re-offers the file, causing duplicate processing in
    production as well.
    
    Fix the race at the source: when keepFile=false, track offered files
    in a listing-thread-private set and only offer a file once for as long
    as it remains on disk. Entries are pruned before each listing snapshot
    once the cleanup thread has renamed or deleted the file, so a stale
    entry can never suppress a legitimate new file with the same name.
    keepFile=true keeps its intentional re-processing semantics.
    
    Also fix two bugs in the tests themselves: the drain loops exited when
    ANY queue was empty (&&) instead of when ALL were empty, and nothing
    waited for the final rename/delete to reach disk before asserting on
    file existence. Replace both with a bounded await that covers the full
    pipeline.
    
    * [fix][io] Fail awaitProcessingComplete on timeout instead of returning 
silently
    
    Returning at the deadline let a test proceed as if the pipeline had
    drained, which could mask a regression the strict times(1) assertions
    are meant to catch. Fail with the queue depths and the number of files
    still on disk instead.
---
 .../apache/pulsar/io/file/FileListingThread.java   | 44 +++++++++++++++++-----
 .../pulsar/io/file/ProcessedFileThreadTest.java    | 44 ++++++++++++++++------
 2 files changed, 67 insertions(+), 21 deletions(-)

diff --git 
a/file/src/main/java/org/apache/pulsar/io/file/FileListingThread.java 
b/file/src/main/java/org/apache/pulsar/io/file/FileListingThread.java
index 5958fc37..10a3c862 100644
--- a/file/src/main/java/org/apache/pulsar/io/file/FileListingThread.java
+++ b/file/src/main/java/org/apache/pulsar/io/file/FileListingThread.java
@@ -44,6 +44,15 @@ public class FileListingThread extends Thread {
     private final AtomicLong queueLastUpdated = new AtomicLong(0L);
     private final Lock listingLock = new ReentrantLock();
     private final AtomicReference<FileFilter> fileFilterRef = new 
AtomicReference<>();
+
+    /**
+     * Files that have already been offered to the work queue and still exist 
on disk.
+     * Only accessed by this thread. Consulting the downstream queues instead 
is racy:
+     * a file is briefly in none of them while the consumer moves it between 
queues, and
+     * again while the cleanup thread renames/deletes it, so a listing pass in 
one of
+     * those windows would offer the same file twice.
+     */
+    private final Set<File> alreadyOffered = new HashSet<>();
     private final BlockingQueue<File> workQueue;
     private final BlockingQueue<File> inProcess;
     private final BlockingQueue<File> recentlyProcessed;
@@ -72,20 +81,37 @@ public class FileListingThread extends Thread {
         while (true) {
             if ((queueLastUpdated.get() < System.currentTimeMillis() - 
pollingInterval) && listingLock.tryLock()) {
                 try {
+                    // Prune tracked files that are gone from disk (processed 
and then renamed or
+                    // deleted). This must happen before the listing snapshot: 
a file that
+                    // disappears between the prune and the listing cannot be 
in the listing, so
+                    // it can never be re-offered through a stale tracking 
entry.
+                    if (!keepOriginal) {
+                        alreadyOffered.removeIf(f -> !f.exists());
+                    }
+
                     final File directory = new File(inputDir);
                     final Set<File> listing = performListing(directory, 
fileFilterRef.get(), recurseDirs);
 
                     if (listing != null && !listing.isEmpty()) {
 
-                        // Remove any files that have been or are currently 
being processed.
-                        listing.removeAll(inProcess);
-                        if (!keepOriginal) {
-                            listing.removeAll(recentlyProcessed);
-                        }
-
-                        for (File f: listing) {
-                            if (!workQueue.contains(f)) {
-                                workQueue.offer(f);
+                        if (keepOriginal) {
+                            // Re-processing the same file is expected in 
keepFile mode: only
+                            // skip files that are currently queued or being 
processed.
+                            listing.removeAll(inProcess);
+                            for (File f: listing) {
+                                if (!workQueue.contains(f)) {
+                                    workQueue.offer(f);
+                                }
+                            }
+                        } else {
+                            // Offer each file exactly once for as long as it 
remains on disk.
+                            // The file stays tracked until the cleanup thread 
renames or
+                            // deletes it, which closes the windows where a 
file is on disk
+                            // but in none of the downstream queues.
+                            for (File f: listing) {
+                                if (alreadyOffered.add(f)) {
+                                    workQueue.offer(f);
+                                }
                             }
                         }
                         queueLastUpdated.set(System.currentTimeMillis());
diff --git 
a/file/src/test/java/org/apache/pulsar/io/file/ProcessedFileThreadTest.java 
b/file/src/test/java/org/apache/pulsar/io/file/ProcessedFileThreadTest.java
index 0b1f8b15..6e4f015d 100644
--- a/file/src/test/java/org/apache/pulsar/io/file/ProcessedFileThreadTest.java
+++ b/file/src/test/java/org/apache/pulsar/io/file/ProcessedFileThreadTest.java
@@ -41,6 +41,32 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
     private ProcessedFileThread cleanupThread;
     private FileSourceConfig fileConfig;
 
+    /**
+     * Waits (bounded) until every produced file has made it through the 
entire pipeline,
+     * including the final rename/delete performed by the cleanup thread. 
Checking only the
+     * queues is not enough: a file is briefly in none of them while it is 
handed from one
+     * thread to the next, and the last hop (disk rename/delete) happens after 
the file has
+     * already left the queues.
+     */
+    private void awaitProcessingComplete() throws InterruptedException {
+        long deadline = System.currentTimeMillis() + 60_000;
+        while (!processingComplete()) {
+            if (System.currentTimeMillis() >= deadline) {
+                fail("Pipeline did not drain within 60s: workQueue=" + 
workQueue.size()
+                        + ", inProcess=" + inProcess.size()
+                        + ", recentlyProcessed=" + recentlyProcessed.size()
+                        + ", files still on disk="
+                        + producedFiles.stream().filter(File::exists).count());
+            }
+            Thread.sleep(200);
+        }
+    }
+
+    private boolean processingComplete() {
+        return workQueue.isEmpty() && inProcess.isEmpty() && 
recentlyProcessed.isEmpty()
+                && producedFiles.stream().noneMatch(File::exists);
+    }
+
     @Test
     public final void singleFileTest() throws IOException {
 
@@ -186,10 +212,8 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Stop producing files
             generatorThread.halt();
 
-            // Let the consumer catch up
-            while (!workQueue.isEmpty() && !inProcess.isEmpty() && 
!recentlyProcessed.isEmpty()) {
-                Thread.sleep(2000);
-            }
+            // Let the pipeline finish processing every produced file
+            awaitProcessingComplete();
 
             // Make sure every single file was processed.
             for (File produced : producedFiles) {
@@ -241,10 +265,8 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Stop producing files
             generatorThread.halt();
 
-            // Let the consumer catch up
-            while (!workQueue.isEmpty() && !inProcess.isEmpty() && 
!recentlyProcessed.isEmpty()) {
-                Thread.sleep(2000);
-            }
+            // Let the pipeline finish processing every produced file
+            awaitProcessingComplete();
 
             // Make sure every single file was processed exactly once.
             for (File produced : producedFiles) {
@@ -293,10 +315,8 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Stop producing files
             generatorThread.halt();
 
-            // Let the consumer catch up
-            while (!workQueue.isEmpty() && !inProcess.isEmpty() && 
!recentlyProcessed.isEmpty()) {
-                Thread.sleep(2000);
-            }
+            // Let the pipeline finish processing every produced file
+            awaitProcessingComplete();
 
 
             // Make sure every single file was processed.

Reply via email to