Repository: AutoMQ/automq (Java)

Issue:
StreamReader cleanup logic may lead to resource leaks
**File:** `s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReaders.java`

**Problem:**

The `cleanupExpiredStreamReader` method in the `Cache` inner class is only triggered by `read()` calls. If a `Cache` instance becomes inactive and receives no new read requests, its expired `StreamReader` objects will not be cleaned up. This can lead to a resource leak, as inactive readers will remain in memory indefinitely.

**Suggestion:**

Implement a proactive cleanup mechanism. Instead of relying on `read()` calls, schedule a periodic task. This task should regularly execute the cleanup logic, ensuring that expired `StreamReader` instances are always reclaimed in a timely manner, regardless of read activity.




Fix (patch):
diff --git a/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReader.java b/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReader.java
index ab6a900f7a..340ffcd5f1 100644
--- a/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReader.java
+++ b/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReader.java
@@ -34,6 +34,7 @@
 import com.automq.stream.s3.objects.ObjectManager;
 import com.automq.stream.utils.FutureUtil;
 import com.automq.stream.utils.LogSuppressor;
+import com.automq.stream.utils.Time;
 import com.automq.stream.utils.threads.EventLoop;
 import com.google.common.annotations.VisibleForTesting;
 
@@ -80,19 +81,27 @@
     private final ObjectManager objectManager;
     private final ObjectReaderFactory objectReaderFactory;
     private final DataBlockCache dataBlockCache;
+    private final Time time;
     long nextReadOffset;
     private CompletableFuture<Void> inflightLoadIndexCf;
     private volatile CompletableFuture<Void> afterReadTryReadaheadCf;
-    private long lastAccessTimestamp = System.currentTimeMillis();
+    private long lastAccessTimestamp;
     private boolean reading = false;
 
     private boolean closed = false;
 
     public StreamReader(long streamId, long nextReadOffset, EventLoop eventLoop, ObjectManager objectManager,
         ObjectReaderFactory objectReaderFactory, DataBlockCache dataBlockCache) {
+        this(streamId, nextReadOffset, eventLoop, objectManager, objectReaderFactory, dataBlockCache, Time.SYSTEM);
+    }
+
+    public StreamReader(long streamId, long nextReadOffset, EventLoop eventLoop, ObjectManager objectManager,
+        ObjectReaderFactory objectReaderFactory, DataBlockCache dataBlockCache, Time time) {
         this.streamId = streamId;
         this.nextReadOffset = nextReadOffset;
         this.readahead = new Readahead();
+        this.time = time;
+        this.lastAccessTimestamp = time.milliseconds();
 
         this.eventLoop = eventLoop;
         this.objectManager = objectManager;
@@ -117,7 +126,7 @@ public CompletableFuture<ReadDataBlock> read(long startOffset, long endOffset, i
     }
 
     CompletableFuture<ReadDataBlock> read(long startOffset, long endOffset, int maxBytes, int leftRetries) {
-        lastAccessTimestamp = System.currentTimeMillis();
+        lastAccessTimestamp = time.milliseconds();
         ReadContext readContext = new ReadContext();
         read0(readContext, startOffset, endOffset, maxBytes);
         CompletableFuture<ReadDataBlock> retCf = new CompletableFuture<>();
@@ -617,7 +626,7 @@ class Readahead {
         private int cacheMissCount;
 
         public void tryReadahead(boolean cacheMiss) {
-            if (System.currentTimeMillis() - resetTimestamp < READAHEAD_RESET_COLD_DOWN_MILLS) {
+            if (time.milliseconds() - resetTimestamp < READAHEAD_RESET_COLD_DOWN_MILLS) {
                 // skip readahead when readahead is in cold down
                 return;
             }
@@ -660,7 +669,7 @@ public void tryReadahead(boolean cacheMiss) {
 
         public void reset() {
             requireReset = true;
-            resetTimestamp = System.currentTimeMillis();
+            resetTimestamp = time.milliseconds();
         }
     }
 
diff --git a/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReaders.java b/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReaders.java
index 4a9e27daee..f1028675a6 100644
--- a/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReaders.java
+++ b/s3stream/src/main/java/com/automq/stream/s3/cache/blockcache/StreamReaders.java
@@ -26,7 +26,10 @@
 import com.automq.stream.s3.trace.context.TraceContext;
 import com.automq.stream.utils.FutureUtil;
 import com.automq.stream.utils.Systems;
+import com.automq.stream.utils.Threads;
+import com.automq.stream.utils.Time;
 import com.automq.stream.utils.threads.EventLoop;
+import com.google.common.annotations.VisibleForTesting;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -43,6 +46,7 @@ public class StreamReaders implements S3BlockCache {
     private static final long STREAM_READER_EXPIRED_MILLS = TimeUnit.MINUTES.toMillis(1);
     private static final long STREAM_READER_EXPIRED_CHECK_INTERVAL_MILLS = TimeUnit.MINUTES.toMillis(1);
     private final Cache[] caches;
+    private final Time time;
     private final DataBlockCache dataBlockCache;
     private final ObjectReaderFactory objectReaderFactory;
 
@@ -51,11 +55,17 @@ public class StreamReaders implements S3BlockCache {
 
     public StreamReaders(long size, ObjectManager objectManager, ObjectStorage objectStorage,
         ObjectReaderFactory objectReaderFactory) {
-        this(size, objectManager, objectStorage, objectReaderFactory, Systems.CPU_CORES);
+        this(size, objectManager, objectStorage, objectReaderFactory, Systems.CPU_CORES, Time.SYSTEM);
     }
 
     public StreamReaders(long size, ObjectManager objectManager, ObjectStorage objectStorage,
         ObjectReaderFactory objectReaderFactory, int concurrency) {
+        this(size, objectManager, objectStorage, objectReaderFactory, concurrency, Time.SYSTEM);
+    }
+
+    public StreamReaders(long size, ObjectManager objectManager, ObjectStorage objectStorage,
+        ObjectReaderFactory objectReaderFactory, int concurrency, Time time) {
+        this.time = time;
         EventLoop[] eventLoops = new EventLoop[concurrency];
         for (int i = 0; i < concurrency; i++) {
             eventLoops[i] = new EventLoop("stream-reader-" + i);
@@ -69,6 +79,11 @@ public StreamReaders(long size, ObjectManager objectManager, ObjectStorage objec
         this.objectReaderFactory = objectReaderFactory;
         this.objectManager = objectManager;
         this.objectStorage = objectStorage;
+
+        Threads.COMMON_SCHEDULER.scheduleAtFixedRate(this::triggerExpiredStreamReaderCleanup,
+            STREAM_READER_EXPIRED_CHECK_INTERVAL_MILLS,
+            STREAM_READER_EXPIRED_CHECK_INTERVAL_MILLS,
+            TimeUnit.MILLISECONDS);
     }
 
     @Override
@@ -78,6 +93,26 @@ public CompletableFuture<ReadDataBlock> read(TraceContext context, long streamId
         return cache.read(streamId, startOffset, endOffset, maxBytes);
     }
 
+    /**
+     * Get the total number of active StreamReaders across all caches.
+     * This method is intended for testing purposes only.
+     */
+    @VisibleForTesting
+    int getActiveStreamReaderCount() {
+        int total = 0;
+        for (Cache cache : caches) {
+            total += cache.getStreamReaderCount();
+        }
+        return total;
+    }
+
+    @VisibleForTesting
+    void triggerExpiredStreamReaderCleanup() {
+        for (Cache cache : caches) {
+            cache.submitCleanupExpiredStreamReader();
+        }
+    }
+
     static class StreamReaderKey {
         final long streamId;
         final long startOffset;
@@ -114,10 +149,11 @@ public String toString() {
     class Cache {
         private final EventLoop eventLoop;
         private final Map<StreamReaderKey, StreamReader> streamReaders = new HashMap<>();
-        private long lastStreamReaderExpiredCheckTime = System.currentTimeMillis();
+        private long lastStreamReaderExpiredCheckTime;
 
         public Cache(EventLoop eventLoop) {
             this.eventLoop = eventLoop;
+            this.lastStreamReaderExpiredCheckTime = time.milliseconds();
         }
 
         public CompletableFuture<ReadDataBlock> read(long streamId, long startOffset,
@@ -129,7 +165,7 @@ public CompletableFuture<ReadDataBlock> read(long streamId, long startOffset,
                 StreamReaderKey key = new StreamReaderKey(streamId, startOffset);
                 StreamReader streamReader = streamReaders.remove(key);
                 if (streamReader == null) {
-                    streamReader = new StreamReader(streamId, startOffset, eventLoop, objectManager, objectReaderFactory, dataBlockCache);
+                    streamReader = new StreamReader(streamId, startOffset, eventLoop, objectManager, objectReaderFactory, dataBlockCache, time);
                 }
                 StreamReader finalStreamReader = streamReader;
                 CompletableFuture<ReadDataBlock> streamReadCf = streamReader.read(startOffset, endOffset, maxBytes)
@@ -150,8 +186,21 @@ public CompletableFuture<ReadDataBlock> read(long streamId, long startOffset,
             return cf;
         }
 
+        private void submitCleanupExpiredStreamReader() {
+            eventLoop.execute(this::cleanupExpiredStreamReader);
+        }
+
+        /**
+         * Get the number of StreamReaders in this cache.
+         * This method is intended for testing purposes only.
+         */
+        @VisibleForTesting
+        int getStreamReaderCount() {
+            return streamReaders.size();
+        }
+
         private void cleanupExpiredStreamReader() {
-            long now = System.currentTimeMillis();
+            long now = time.milliseconds();
             if (now > lastStreamReaderExpiredCheckTime + STREAM_READER_EXPIRED_CHECK_INTERVAL_MILLS) {
                 lastStreamReaderExpiredCheckTime = now;
                 Iterator<Map.Entry<StreamReaderKey, StreamReader>> it = streamReaders.entrySet().iterator();