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 10f9daee [fix][io] Prevent OOM in FileSource by bounding internal 
queues (#82)
10f9daee is described below

commit 10f9daee19f86a66dd78a75c694cdc98e953060c
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Fri Jul 10 06:21:35 2026 -0700

    [fix][io] Prevent OOM in FileSource by bounding internal queues (#82)
    
    * [fix][io] Prevent OOM in FileSource by bounding internal queues
    
    * revert with old changes for specific getfile method
    
    * [fix][io] Block instead of throwing when bounded queues are full
    
    Address the review findings on the bounded-queue change:
    
    FileConsumerThread produced into inProcess and recentlyProcessed with
    BlockingQueue.add(), which throws IllegalStateException on a bounded
    queue that is full rather than returning false — so the surrounding
    do/while(!added) retry loop never ran. Once the queues are bounded, a
    cleanup thread slower than the workers would kill the worker outright
    and stall processing. Use put() so the producer blocks and applies
    back-pressure. Existing tests verified add() on the spied queues and
    follow the change.
    
    FileSourceConfig.validate() permitted a null maxQueueSize, which
    FileSource.open() then unboxed to size the queues, turning a config
    error into a NullPointerException. Reject null explicitly.
    
    FileSourceConfigTest hard-coded /tmp as a dummy input directory; use
    the existing INPUT_DIRECTORY constant so the tests do not depend on
    filesystem layout.
    
    Add FileConsumerThreadBoundedQueueTest, which fills recentlyProcessed
    and asserts the consumer blocks and survives; it fails against add()
    with 'consumer thread died: expected 3 files, got 1'.
    
    ---------
    
    Co-authored-by: Praveenkumar76 <[email protected]>
---
 .../apache/pulsar/io/file/FileConsumerThread.java  | 15 ++--
 .../java/org/apache/pulsar/io/file/FileSource.java | 12 ++-
 .../apache/pulsar/io/file/FileSourceConfig.java    | 12 +++
 .../file/FileConsumerThreadBoundedQueueTest.java   | 90 ++++++++++++++++++++++
 .../pulsar/io/file/FileConsumerThreadTest.java     | 24 +++---
 .../pulsar/io/file/FileSourceConfigTest.java       | 65 ++++++++++++++++
 .../pulsar/io/file/ProcessedFileThreadTest.java    | 34 ++++----
 7 files changed, 211 insertions(+), 41 deletions(-)

diff --git 
a/file/src/main/java/org/apache/pulsar/io/file/FileConsumerThread.java 
b/file/src/main/java/org/apache/pulsar/io/file/FileConsumerThread.java
index 7e0c9344..8845da49 100644
--- a/file/src/main/java/org/apache/pulsar/io/file/FileConsumerThread.java
+++ b/file/src/main/java/org/apache/pulsar/io/file/FileConsumerThread.java
@@ -56,10 +56,9 @@ public class FileConsumerThread extends Thread {
             while (true) {
                 File file = workQueue.take();
 
-                boolean added = false;
-                do {
-                    added = inProcess.add(file);
-                } while (!added);
+                // put(), not add(): add() throws IllegalStateException on a 
bounded queue that is
+                // full, which would kill this worker. Blocking applies 
back-pressure instead.
+                inProcess.put(file);
 
                 consumeFile(file);
             }
@@ -68,7 +67,7 @@ public class FileConsumerThread extends Thread {
         }
     }
 
-    private void consumeFile(File file) {
+    private void consumeFile(File file) throws InterruptedException {
         final AtomicInteger idx = new AtomicInteger(1);
         try (Stream<String> lines = getLines(file)) {
              lines.forEachOrdered(line -> process(file, idx.getAndIncrement(), 
line));
@@ -81,10 +80,8 @@ public class FileConsumerThread extends Thread {
                 removed = inProcess.remove(file);
             } while (!removed);
 
-            boolean added = false;
-            do {
-                added = recentlyProcessed.add(file);
-            } while (!added);
+            // put(), not add(): see the comment in run().
+            recentlyProcessed.put(file);
         }
     }
 
diff --git a/file/src/main/java/org/apache/pulsar/io/file/FileSource.java 
b/file/src/main/java/org/apache/pulsar/io/file/FileSource.java
index 85d46c51..70780929 100644
--- a/file/src/main/java/org/apache/pulsar/io/file/FileSource.java
+++ b/file/src/main/java/org/apache/pulsar/io/file/FileSource.java
@@ -36,15 +36,21 @@ import org.apache.pulsar.io.core.SourceContext;
 public class FileSource extends PushSource<byte[]> {
 
     private ExecutorService executor;
-    private final BlockingQueue<File> workQueue = new LinkedBlockingQueue<>();
-    private final BlockingQueue<File> inProcess = new LinkedBlockingQueue<>();
-    private final BlockingQueue<File> recentlyProcessed = new 
LinkedBlockingQueue<>();
+    private BlockingQueue<File> workQueue;
+    private BlockingQueue<File> inProcess;
+    private BlockingQueue<File> recentlyProcessed;
 
     @Override
     public void open(Map<String, Object> config, SourceContext sourceContext) 
throws Exception {
         FileSourceConfig fileConfig = FileSourceConfig.load(config);
         fileConfig.validate();
 
+        // Initialize bounded queues based on configuration to prevent OOM
+        int queueCapacity = fileConfig.getMaxQueueSize();
+        workQueue = new LinkedBlockingQueue<>(queueCapacity);
+        inProcess = new LinkedBlockingQueue<>(queueCapacity);
+        recentlyProcessed = new LinkedBlockingQueue<>(queueCapacity);
+
         // One extra for the File listing task, and another for the cleanup 
thread
         executor = Executors.newFixedThreadPool(fileConfig.getNumWorkers() + 
2);
         executor.execute(new FileListingTask(fileConfig, workQueue, inProcess, 
recentlyProcessed));
diff --git a/file/src/main/java/org/apache/pulsar/io/file/FileSourceConfig.java 
b/file/src/main/java/org/apache/pulsar/io/file/FileSourceConfig.java
index d9b1e1f3..40e11201 100644
--- a/file/src/main/java/org/apache/pulsar/io/file/FileSourceConfig.java
+++ b/file/src/main/java/org/apache/pulsar/io/file/FileSourceConfig.java
@@ -110,6 +110,12 @@ public class FileSourceConfig implements Serializable {
      */
     private Integer numWorkers = 1;
 
+    /**
+     * The maximum capacity of the internal queues. This provides backpressure
+     * to prevent OutOfMemoryErrors when processing is slower than file 
listing.
+     */
+    private Integer maxQueueSize = 1000;
+
     /**
      * If set, do not delete but only rename file that has been processed.
      * This config only work when 'keepFile' property is false.
@@ -170,6 +176,12 @@ public class FileSourceConfig implements Serializable {
             throw new IllegalArgumentException("The property numWorkers must 
be greater than zero");
         }
 
+        // FileSource.open() unboxes maxQueueSize to size the bounded queues, 
so an explicit null
+        // would be an NPE there rather than a config error here.
+        if (maxQueueSize == null || maxQueueSize <= 0) {
+            throw new IllegalArgumentException("The property maxQueueSize must 
be greater than zero");
+        }
+
         if (processedFileSuffix != null && keepFile) {
             throw new IllegalArgumentException(
                     "The property keepFile must be false if the property 
processedFileSuffix is set");
diff --git 
a/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadBoundedQueueTest.java
 
b/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadBoundedQueueTest.java
new file mode 100644
index 00000000..927e8b8b
--- /dev/null
+++ 
b/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadBoundedQueueTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.pulsar.io.file;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.verify;
+import static org.testng.Assert.fail;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.core.PushSource;
+import org.awaitility.Awaitility;
+import org.mockito.Mockito;
+import org.testng.annotations.Test;
+
+/**
+ * The consumer must survive a full {@code recentlyProcessed} queue.
+ *
+ * <p>{@code BlockingQueue.add()} throws {@link IllegalStateException} when a 
bounded queue is
+ * full — it does not return {@code false}, so the surrounding {@code 
do/while(!added)} retry loop
+ * never runs. Once the queues are bounded, a cleanup thread slower than the 
workers would kill
+ * the consumer thread outright and stall processing. The producers must block 
instead.
+ */
+@SuppressWarnings("unchecked")
+public class FileConsumerThreadBoundedQueueTest extends AbstractFileTest {
+
+    /**
+     * With no cleanup thread draining {@code recentlyProcessed}, the consumer 
fills it and must
+     * then block rather than throw. Draining it lets the remaining files 
through.
+     */
+    @Test
+    public final void 
consumerBlocksInsteadOfCrashingWhenDownstreamQueueIsFull() throws IOException {
+        PushSource<byte[]> consumer = Mockito.mock(PushSource.class);
+        Mockito.doNothing().when(consumer).consume((Record<byte[]>) 
any(Record.class));
+
+        BlockingQueue<File> work = new LinkedBlockingQueue<>();
+        BlockingQueue<File> inProcess = new LinkedBlockingQueue<>(10);
+        // Nothing drains this, so it fills after one file.
+        BlockingQueue<File> recentlyProcessed = new LinkedBlockingQueue<>(1);
+
+        try {
+            generateFiles(3);
+            for (File f : producedFiles) {
+                work.put(f);
+            }
+
+            executor.execute(new FileConsumerThread(consumer, work, inProcess, 
recentlyProcessed));
+
+            // The first file lands in recentlyProcessed; the consumer then 
blocks on the second.
+            Awaitility.await().atMost(10, TimeUnit.SECONDS)
+                    .until(() -> recentlyProcessed.size() == 1);
+
+            // Drain as the cleanup thread would. Every file must be handed 
over: the consumer
+            // must still be alive, having blocked rather than thrown 
IllegalStateException.
+            for (int i = 0; i < 3; i++) {
+                File taken = recentlyProcessed.poll(10, TimeUnit.SECONDS);
+                if (taken == null) {
+                    fail("consumer thread died: expected 3 files through 
recentlyProcessed, got " + i);
+                }
+            }
+
+            verify(consumer, atLeast(3)).consume(any(Record.class));
+        } catch (InterruptedException | ExecutionException e) {
+            fail("Unable to generate files" + e.getLocalizedMessage());
+        } finally {
+            cleanUp();
+        }
+    }
+}
diff --git 
a/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadTest.java 
b/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadTest.java
index 6f2a182e..238fc284 100644
--- a/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadTest.java
+++ b/file/src/test/java/org/apache/pulsar/io/file/FileConsumerThreadTest.java
@@ -58,16 +58,16 @@ public class FileConsumerThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
             verify(workQueue, times(1)).offer(any(File.class));
             verify(workQueue, atLeast(1)).take();
-            verify(inProcess, times(1)).add(any(File.class));
+            verify(inProcess, times(1)).put(any(File.class));
             verify(inProcess, times(1)).remove(any(File.class));
-            verify(recentlyProcessed, times(1)).add(any(File.class));
+            verify(recentlyProcessed, times(1)).put(any(File.class));
             verify(consumer, times(1)).consume((Record<byte[]>) 
any(Record.class));
         } catch (InterruptedException | ExecutionException e) {
             fail("Unable to generate files" + e.getLocalizedMessage());
@@ -93,16 +93,16 @@ public class FileConsumerThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
             verify(workQueue, times(50)).offer(any(File.class));
             verify(workQueue, atLeast(50)).take();
-            verify(inProcess, times(50)).add(any(File.class));
+            verify(inProcess, times(50)).put(any(File.class));
             verify(inProcess, times(50)).remove(any(File.class));
-            verify(recentlyProcessed, times(50)).add(any(File.class));
+            verify(recentlyProcessed, times(50)).put(any(File.class));
             verify(consumer, times(100)).consume((Record<byte[]>) 
any(Record.class));
         } catch (InterruptedException | ExecutionException e) {
             fail("Unable to generate files" + e.getLocalizedMessage());
@@ -128,16 +128,16 @@ public class FileConsumerThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
             verify(workQueue, times(1)).offer(any(File.class));
             verify(workQueue, atLeast(1)).take();
-            verify(inProcess, times(1)).add(any(File.class));
+            verify(inProcess, times(1)).put(any(File.class));
             verify(inProcess, times(1)).remove(any(File.class));
-            verify(recentlyProcessed, times(1)).add(any(File.class));
+            verify(recentlyProcessed, times(1)).put(any(File.class));
             verify(consumer, times(10)).consume((Record<byte[]>) 
any(Record.class));
         } catch (InterruptedException | ExecutionException e) {
             fail("Unable to generate files" + e.getLocalizedMessage());
diff --git 
a/file/src/test/java/org/apache/pulsar/io/file/FileSourceConfigTest.java 
b/file/src/test/java/org/apache/pulsar/io/file/FileSourceConfigTest.java
index e7d497f5..20df1451 100644
--- a/file/src/test/java/org/apache/pulsar/io/file/FileSourceConfigTest.java
+++ b/file/src/test/java/org/apache/pulsar/io/file/FileSourceConfigTest.java
@@ -18,8 +18,10 @@
  */
 package org.apache.pulsar.io.file;
 
+import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 import java.io.File;
 import java.io.IOException;
@@ -142,6 +144,69 @@ public class FileSourceConfigTest {
         config.validate();
     }
 
+    @Test
+    public void testDefaultMaxQueueSize() throws Exception {
+        Map<String, Object> map = new HashMap<>();
+        map.put("inputDirectory", INPUT_DIRECTORY);
+
+        FileSourceConfig config = FileSourceConfig.load(map);
+
+        // Assert that if the user provides no value, it defaults to 1000
+        assertEquals(config.getMaxQueueSize(), Integer.valueOf(1000));
+    }
+
+    @Test
+    public void testValidMaxQueueSize() throws Exception {
+        Map<String, Object> map = new HashMap<>();
+        map.put("inputDirectory", INPUT_DIRECTORY);
+        map.put("maxQueueSize", 5000);
+
+        FileSourceConfig config = FileSourceConfig.load(map);
+
+        // Assert that the custom value is loaded correctly
+        assertEquals(config.getMaxQueueSize(), Integer.valueOf(5000));
+
+        // Should not throw any exceptions
+        config.validate();
+    }
+
+    @Test
+    public void testInvalidMaxQueueSize() {
+        Map<String, Object> map = new HashMap<>();
+        map.put("inputDirectory", INPUT_DIRECTORY);
+
+        // Test 0
+        map.put("maxQueueSize", 0);
+        assertThrows(IllegalArgumentException.class, () -> {
+            FileSourceConfig config = FileSourceConfig.load(map);
+            config.validate();
+        });
+
+        // Test Negative Number
+        map.put("maxQueueSize", -5);
+        assertThrows(IllegalArgumentException.class, () -> {
+            FileSourceConfig config = FileSourceConfig.load(map);
+            config.validate();
+        });
+    }
+
+    /**
+     * An explicit null must be rejected by validate(), not deferred to 
FileSource.open(),
+     * which unboxes maxQueueSize to size the bounded queues and would throw a
+     * NullPointerException instead of a useful config error.
+     */
+    @Test
+    public void testNullMaxQueueSizeRejected() {
+        Map<String, Object> map = new HashMap<>();
+        map.put("inputDirectory", INPUT_DIRECTORY);
+        map.put("maxQueueSize", null);
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            FileSourceConfig config = FileSourceConfig.load(map);
+            config.validate();
+        });
+    }
+
     private File getFile(String name) {
         ClassLoader classLoader = getClass().getClassLoader();
         return new File(classLoader.getResource(name).getFile());
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 4d109cb7..2594948b 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
@@ -90,16 +90,16 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
             verify(workQueue, times(1)).offer(any(File.class));
             verify(workQueue, atLeast(1)).take();
-            verify(inProcess, times(1)).add(any(File.class));
+            verify(inProcess, times(1)).put(any(File.class));
             verify(inProcess, times(1)).remove(any(File.class));
-            verify(recentlyProcessed, times(1)).add(any(File.class));
+            verify(recentlyProcessed, times(1)).put(any(File.class));
             verify(recentlyProcessed, times(2)).take();
         } catch (InterruptedException | ExecutionException e) {
             fail("Unable to generate files" + e.getLocalizedMessage());
@@ -129,17 +129,17 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
             verify(workQueue, times(50)).offer(any(File.class));
             verify(workQueue, atLeast(50)).take();
-            verify(inProcess, times(50)).add(any(File.class));
+            verify(inProcess, times(50)).put(any(File.class));
             verify(inProcess, times(50)).remove(any(File.class));
-            verify(recentlyProcessed, times(50)).add(any(File.class));
-            verify(recentlyProcessed, times(50)).add(any(File.class));
+            verify(recentlyProcessed, times(50)).put(any(File.class));
+            verify(recentlyProcessed, times(50)).put(any(File.class));
             verify(recentlyProcessed, times(51)).take();
         } catch (InterruptedException | ExecutionException e) {
             fail("Unable to generate files" + e.getLocalizedMessage());
@@ -170,9 +170,9 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
 
             for (File produced : producedFiles) {
                 verify(workQueue, atLeast(4)).offer(produced);
-                verify(inProcess, atLeast(4)).add(produced);
+                verify(inProcess, atLeast(4)).put(produced);
                 verify(inProcess, atLeast(4)).remove(produced);
-                verify(recentlyProcessed, atLeast(4)).add(produced);
+                verify(recentlyProcessed, atLeast(4)).put(produced);
             }
 
             verify(recentlyProcessed, atLeast(5)).take();
@@ -218,9 +218,9 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Make sure every single file was processed.
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
         } catch (InterruptedException e) {
@@ -271,9 +271,9 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Make sure every single file was processed exactly once.
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
             }
 
         } catch (InterruptedException e) {
@@ -322,9 +322,9 @@ public class ProcessedFileThreadTest extends 
AbstractFileTest {
             // Make sure every single file was processed.
             for (File produced : producedFiles) {
                 verify(workQueue, times(1)).offer(produced);
-                verify(inProcess, times(1)).add(produced);
+                verify(inProcess, times(1)).put(produced);
                 verify(inProcess, times(1)).remove(produced);
-                verify(recentlyProcessed, times(1)).add(produced);
+                verify(recentlyProcessed, times(1)).put(produced);
 
                 assert(!produced.exists());
                 assert(new File(produced.getAbsolutePath() + 
processedFileSuffix).exists());

Reply via email to