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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 8e9868d464 [flink] Avoid having QueryFileMonitor always busy (#9291)
8e9868d464 is described below

commit 8e9868d4645540864c2790959c072799b3db2bf9
Author: Eunbin Son <[email protected]>
AuthorDate: Mon Aug 24 00:27:06 2026 +0900

    [flink] Avoid having QueryFileMonitor always busy (#9291)
---
 .../paimon/flink/service/QueryFileMonitor.java     |  61 +++++-
 .../paimon/flink/service/QueryFileMonitorTest.java | 222 +++++++++++++++++++++
 2 files changed, 281 insertions(+), 2 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/service/QueryFileMonitor.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/service/QueryFileMonitor.java
index 6688503778..ebc6f7bf3e 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/service/QueryFileMonitor.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/service/QueryFileMonitor.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.flink.service;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.flink.source.AbstractNonCoordinatedSource;
@@ -33,6 +34,7 @@ import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.StreamTableScan;
 import org.apache.paimon.table.source.TableRead;
 import org.apache.paimon.table.system.FileMonitorTable;
+import org.apache.paimon.utils.ExecutorThreadFactory;
 
 import org.apache.flink.api.common.eventtime.WatermarkStrategy;
 import org.apache.flink.api.connector.source.Boundedness;
@@ -45,6 +47,11 @@ import 
org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 
 import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
 
@@ -79,12 +86,35 @@ public class QueryFileMonitor extends 
AbstractNonCoordinatedSource<InternalRow>
     @Override
     public SourceReader<InternalRow, SimpleSourceSplit> createReader(
             SourceReaderContext sourceReaderContext) throws Exception {
-        return new Reader();
+        return new Reader(
+                Executors.newSingleThreadScheduledExecutor(
+                        new ExecutorThreadFactory("query-file-monitor-timer")),
+                true);
+    }
+
+    @VisibleForTesting
+    SourceReader<InternalRow, SimpleSourceSplit> createReaderWithTimer(
+            ScheduledExecutorService timer) {
+        return new Reader(timer, false);
     }
 
     private class Reader extends 
AbstractNonCoordinatedSourceReader<InternalRow> {
+
+        /** Fires the delayed wake-up without occupying a thread while the 
delay elapses. */
+        private final ScheduledExecutorService timer;
+
+        /** Whether this reader created {@link #timer} and therefore has to 
shut it down. */
+        private final boolean ownsTimer;
+
         private transient StreamTableScan scan;
         private transient TableRead read;
+        private CompletableFuture<Void> availableFuture = 
CompletableFuture.completedFuture(null);
+        private ScheduledFuture<?> pendingWakeUp;
+
+        private Reader(ScheduledExecutorService timer, boolean ownsTimer) {
+            this.timer = timer;
+            this.ownsTimer = ownsTimer;
+        }
 
         @Override
         public void start() {
@@ -94,16 +124,43 @@ public class QueryFileMonitor extends 
AbstractNonCoordinatedSource<InternalRow>
             this.read = readBuilder.newRead();
         }
 
+        @Override
+        public CompletableFuture<Void> isAvailable() {
+            return availableFuture;
+        }
+
         @Override
         public InputStatus pollNext(ReaderOutput<InternalRow> readerOutput) 
throws Exception {
             boolean isEmpty = doScan(readerOutput);
 
             if (isEmpty) {
-                Thread.sleep(monitorInterval);
+                CompletableFuture<Void> future = new CompletableFuture<>();
+                availableFuture = future;
+                pendingWakeUp =
+                        timer.schedule(
+                                () -> {
+                                    future.complete(null);
+                                },
+                                monitorInterval,
+                                TimeUnit.MILLISECONDS);
+                return InputStatus.NOTHING_AVAILABLE;
             }
             return InputStatus.MORE_AVAILABLE;
         }
 
+        @Override
+        public void close() throws Exception {
+            if (pendingWakeUp != null) {
+                pendingWakeUp.cancel(false);
+                pendingWakeUp = null;
+            }
+            // unblock anyone still waiting on availability instead of leaving 
them hanging
+            availableFuture.complete(null);
+            if (ownsTimer) {
+                timer.shutdownNow();
+            }
+        }
+
         private boolean doScan(ReaderOutput<InternalRow> readerOutput) throws 
Exception {
             List<InternalRow> records = new ArrayList<>();
             read.createReader(scan.plan()).forEachRemaining(records::add);
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/service/QueryFileMonitorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/service/QueryFileMonitorTest.java
new file mode 100644
index 0000000000..3a1af8ca27
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/service/QueryFileMonitorTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.paimon.flink.service;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.catalog.CatalogFactory;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.source.SimpleSourceSplit;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.types.DataTypes;
+
+import org.apache.flink.api.common.eventtime.Watermark;
+import org.apache.flink.api.connector.source.ReaderOutput;
+import org.apache.flink.api.connector.source.SourceOutput;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.core.io.InputStatus;
+import 
org.apache.flink.core.testutils.ManuallyTriggeredScheduledExecutorService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link QueryFileMonitor}. */
+public class QueryFileMonitorTest {
+
+    private static final long DISCOVERY_INTERVAL_MS = 3_000L;
+
+    @TempDir Path tempDir;
+
+    private Table table;
+
+    @BeforeEach
+    public void before() throws Exception {
+        Catalog catalog =
+                CatalogFactory.createCatalog(
+                        CatalogContext.create(new 
org.apache.paimon.fs.Path(tempDir.toUri())));
+        Schema schema =
+                Schema.newBuilder()
+                        .column("a", DataTypes.INT())
+                        .column("b", DataTypes.INT())
+                        .column("c", DataTypes.INT())
+                        .primaryKey("a")
+                        .option("bucket", "1")
+                        
.option(CoreOptions.CONTINUOUS_DISCOVERY_INTERVAL.key(), "3 s")
+                        .build();
+        Identifier identifier = Identifier.create("default", "t");
+        catalog.createDatabase("default", false);
+        catalog.createTable(identifier, schema, false);
+        this.table = catalog.getTable(identifier);
+    }
+
+    @Test
+    public void testPollWithoutNewFilesReportsNothingAvailable() throws 
Exception {
+        SourceReader<InternalRow, SimpleSourceSplit> reader =
+                new QueryFileMonitor(table).createReader(null);
+        try {
+            reader.start();
+            TestingReaderOutput<InternalRow> output = new 
TestingReaderOutput<>();
+
+            InputStatus status = reader.pollNext(output);
+
+            assertThat(status).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+            assertThat(output.getEmittedRecords()).isEmpty();
+            // the poll must not block the mailbox thread for the discovery 
interval
+            assertThat(reader.isAvailable().isDone()).isFalse();
+            // availability is restored once the discovery interval has elapsed
+            reader.isAvailable().get(DISCOVERY_INTERVAL_MS * 10, 
TimeUnit.MILLISECONDS);
+        } finally {
+            reader.close();
+        }
+    }
+
+    @Test
+    public void testPollWithNewFilesReportsMoreAvailable() throws Exception {
+        writeToTable(1, 2, 3);
+        SourceReader<InternalRow, SimpleSourceSplit> reader =
+                new QueryFileMonitor(table).createReader(null);
+        try {
+            reader.start();
+            TestingReaderOutput<InternalRow> output = new 
TestingReaderOutput<>();
+
+            
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.MORE_AVAILABLE);
+            assertThat(output.getEmittedRecords()).isNotEmpty();
+            assertThat(reader.isAvailable().isDone()).isTrue();
+        } finally {
+            reader.close();
+        }
+    }
+
+    @Test
+    public void testConcurrentWaitsDoNotBlockEachOther() throws Exception {
+        ManuallyTriggeredScheduledExecutorService timer =
+                new ManuallyTriggeredScheduledExecutorService();
+        QueryFileMonitor monitor = new QueryFileMonitor(table);
+        SourceReader<InternalRow, SimpleSourceSplit> first = 
monitor.createReaderWithTimer(timer);
+        SourceReader<InternalRow, SimpleSourceSplit> second = 
monitor.createReaderWithTimer(timer);
+        try {
+            first.start();
+            second.start();
+            TestingReaderOutput<InternalRow> output = new 
TestingReaderOutput<>();
+
+            
assertThat(first.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+            
assertThat(second.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+
+            // each wait is a queued timer task instead of a thread sleeping 
for the interval, so
+            // the second reader's delay is already pending while the first 
one has not elapsed
+            assertThat(timer.getAllNonPeriodicScheduledTask()).hasSize(2);
+            assertThat(first.isAvailable().isDone()).isFalse();
+            assertThat(second.isAvailable().isDone()).isFalse();
+
+            timer.triggerNonPeriodicScheduledTasks();
+
+            assertThat(first.isAvailable().isDone()).isTrue();
+            assertThat(second.isAvailable().isDone()).isTrue();
+        } finally {
+            first.close();
+            second.close();
+        }
+    }
+
+    @Test
+    public void testCloseCancelsThePendingWait() throws Exception {
+        ManuallyTriggeredScheduledExecutorService timer =
+                new ManuallyTriggeredScheduledExecutorService();
+        SourceReader<InternalRow, SimpleSourceSplit> reader =
+                new QueryFileMonitor(table).createReaderWithTimer(timer);
+        reader.start();
+
+        assertThat(reader.pollNext(new TestingReaderOutput<>()))
+                .isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        ScheduledFuture<?> wakeUp = 
timer.getAllNonPeriodicScheduledTask().get(0);
+        assertThat(wakeUp.isCancelled()).isFalse();
+
+        reader.close();
+
+        // the delay does not outlive the reader
+        assertThat(wakeUp.isCancelled()).isTrue();
+        assertThat(reader.isAvailable().isDone()).isTrue();
+    }
+
+    private void writeToTable(int a, int b, int c) throws Exception {
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        BatchTableWrite write = writeBuilder.newWrite();
+        write.write(GenericRow.of(a, b, c));
+        BatchTableCommit commit = writeBuilder.newCommit();
+        commit.commit(write.prepareCommit());
+        write.close();
+        commit.close();
+    }
+
+    private static final class TestingReaderOutput<E> implements 
ReaderOutput<E> {
+
+        private final ArrayList<E> emittedRecords = new ArrayList<>();
+
+        @Override
+        public void collect(E record) {
+            emittedRecords.add(record);
+        }
+
+        @Override
+        public void collect(E record, long timestamp) {
+            collect(record);
+        }
+
+        @Override
+        public void emitWatermark(Watermark watermark) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void markIdle() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void markActive() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public SourceOutput<E> createOutputForSplit(String splitId) {
+            return this;
+        }
+
+        @Override
+        public void releaseOutputForSplit(String splitId) {}
+
+        public ArrayList<E> getEmittedRecords() {
+            return emittedRecords;
+        }
+    }
+}

Reply via email to