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

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


The following commit(s) were added to refs/heads/master by this push:
     new 10749a27f35 Release subscription WAL buffers between prefetch rounds 
(#18956)
10749a27f35 is described below

commit 10749a27f354022c5d0b9779912db1a0efae3b4c
Author: Caideyipi <[email protected]>
AuthorDate: Wed Oct 21 16:05:30 2026 +0800

    Release subscription WAL buffers between prefetch rounds (#18956)
    
    * Release subscription WAL read buffers between prefetch rounds
    
    * Stream Prometheus snapshots in bounded UTF-8 chunks
    
    * Handle shortened WAL segments after buffer release
---
 .../dataregion/wal/io/ProgressWALReader.java       |  4 ++
 .../dataregion/wal/io/WALByteBufReader.java        |  4 ++
 .../dataregion/wal/io/WALInputStream.java          | 45 ++++++++++++-
 .../consensus/ConsensusPrefetchingQueue.java       | 10 +++
 .../broker/consensus/ProgressWALIterator.java      |  9 +++
 .../wal/compression/WALCompressionTest.java        | 73 +++++++++++++++++++++
 .../dataregion/wal/io/ProgressWALReaderTest.java   | 75 ++++++++++++++++++++++
 .../broker/consensus/ProgressWALIteratorTest.java  |  3 +
 .../reporter/prometheus/PrometheusReporter.java    | 26 +++++++-
 .../prometheus/PrometheusReporterTest.java         | 57 ++++++++++++++++
 10 files changed, 303 insertions(+), 3 deletions(-)

diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReader.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReader.java
index 9b6c67e0716..bac5c17c45f 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReader.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReader.java
@@ -78,6 +78,10 @@ public class ProgressWALReader implements Closeable {
     return delegate.getCurrentEntryIndex();
   }
 
+  public void releaseReadBuffers() {
+    delegate.releaseReadBuffers();
+  }
+
   @Override
   public void close() throws IOException {
     delegate.close();
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALByteBufReader.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALByteBufReader.java
index f89b1fb5393..cd4e9c2ce50 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALByteBufReader.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALByteBufReader.java
@@ -132,6 +132,10 @@ public class WALByteBufReader implements Closeable {
     return metaData;
   }
 
+  public void releaseReadBuffers() {
+    walInputStream.releaseReadBuffers();
+  }
+
   @Override
   public void close() throws IOException {
     logStream.close();
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALInputStream.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALInputStream.java
index 1c20982be67..d4b4a8b78f9 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALInputStream.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/WALInputStream.java
@@ -52,6 +52,10 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
   private final ByteBuffer compressedSizeBuffer = 
ByteBuffer.allocate(Integer.BYTES);
   private ByteBuffer dataBuffer = null;
   private ByteBuffer compressedBuffer = null;
+  private long currentSegmentOffset = -1;
+  private long suspendedSegmentOffset = -1;
+  private int suspendedSegmentPosition;
+  private int suspendedSegmentRemaining;
   private final long fileSize;
   File logFile;
   /*
@@ -205,7 +209,26 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
 
   @Override
   public void close() throws IOException {
-    channel.close();
+    try {
+      channel.close();
+    } finally {
+      releaseReadBuffers();
+      suspendedSegmentOffset = -1;
+      suspendedSegmentRemaining = 0;
+    }
+  }
+
+  /**
+   * Releases segment scratch buffers while preserving the unread position. A 
paused reader must not
+   * pin a complete WAL segment for each subscription queue. The next read 
reloads only the current
+   * segment, without reopening the file or rescanning entry metadata.
+   */
+  public void releaseReadBuffers() {
+    if (dataBuffer != null && dataBuffer.hasRemaining()) {
+      suspendedSegmentOffset = currentSegmentOffset;
+      suspendedSegmentPosition = dataBuffer.position();
+      suspendedSegmentRemaining = dataBuffer.remaining();
+    }
     MmapUtil.clean(dataBuffer);
     MmapUtil.clean(compressedBuffer);
     dataBuffer = null;
@@ -217,11 +240,16 @@ public class WALInputStream extends InputStream 
implements AutoCloseable {
     long size = (endOffset - channel.position());
     if (!Objects.isNull(dataBuffer)) {
       size += dataBuffer.limit() - dataBuffer.position();
+    } else {
+      size += suspendedSegmentRemaining;
     }
     return (int) size;
   }
 
   private void loadNextSegment() throws IOException {
+    if (suspendedSegmentOffset >= 0) {
+      channel.position(suspendedSegmentOffset);
+    }
     if (channel.position() >= endOffset) {
       throw new 
EOFException(StorageEngineMessages.REACH_END_OFFSET_OF_WAL_FILE);
     }
@@ -234,6 +262,15 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
     } else {
       tryLoadSegment();
     }
+    currentSegmentOffset = startPosition;
+    if (suspendedSegmentOffset >= 0) {
+      if (suspendedSegmentPosition >= dataBuffer.limit()) {
+        throw new EOFException(StorageEngineMessages.UNEXPECTED_END_OF_FILE);
+      }
+      dataBuffer.position(suspendedSegmentPosition);
+      suspendedSegmentOffset = -1;
+      suspendedSegmentRemaining = 0;
+    }
     WritingMetrics.getInstance()
         .recordWALRead(channel.position() - startPosition, System.nanoTime() - 
startTime);
   }
@@ -349,6 +386,9 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
    * @throws IOException If the file is broken or the given position is invalid
    */
   public void skipToGivenLogicalPosition(long pos) throws IOException {
+    releaseReadBuffers();
+    suspendedSegmentOffset = -1;
+    suspendedSegmentRemaining = 0;
     if (version == WALFileVersion.V2 || version == WALFileVersion.V3) {
       channel.position(version.getVersionBytes().length);
       long posRemain = pos;
@@ -360,6 +400,7 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
           posRemain -= segmentInfo.uncompressedSize;
           channel.position(currentPos + segmentInfo.dataInDiskSize + 
segmentInfo.headerSize());
         } else {
+          currentSegmentOffset = currentPos;
           break;
         }
       } while (posRemain >= 0);
@@ -389,7 +430,7 @@ public class WALInputStream extends InputStream implements 
AutoCloseable {
   public void read(ByteBuffer buffer) throws IOException {
     int totalBytesToBeRead = buffer.remaining();
     while (totalBytesToBeRead > 0) {
-      if (dataBuffer.remaining() == 0) {
+      if (dataBuffer == null || dataBuffer.remaining() == 0) {
         loadNextSegment();
       }
       int currReadBytes = Math.min(dataBuffer.remaining(), totalBytesToBeRead);
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
index 812fc0f6f7d..3f0561d37f6 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
@@ -1948,6 +1948,16 @@ public class ConsensusPrefetchingQueue {
     } finally {
       final ProgressWALIterator iterator = subscriptionWALIterator;
       walReplayState = Objects.nonNull(iterator) ? iterator.toString() : 
"UNAVAILABLE";
+      // Readers outlive a round, but their direct segment buffers must not be 
retained by every
+      // inactive/backpressured queue. Keep their cursors and request 
fragments for the next round.
+      if (Objects.nonNull(iterator)) {
+        iterator.releaseReadBuffers();
+      }
+      for (final WriterReplayState state : orderedWriterReplay.values()) {
+        if (Objects.nonNull(state.iterator)) {
+          state.iterator.releaseReadBuffers();
+        }
+      }
       releaseReadLock();
     }
   }
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
index 4b1a58a3009..07438858a35 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
@@ -562,6 +562,15 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
     resetCurrentFileTracking();
   }
 
+  /**
+   * Drops segment buffers between prefetch rounds without discarding buffered 
request fragments.
+   */
+  public void releaseReadBuffers() {
+    if (currentReader != null) {
+      currentReader.releaseReadBuffers();
+    }
+  }
+
   private IndexedConsensusRequest advance(final ScanBudget scanBudget) throws 
IOException {
     while (true) {
       if (scanBudget != null && scanBudget.shouldYield()) {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/compression/WALCompressionTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/compression/WALCompressionTest.java
index abbc766deb9..07c317c4bff 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/compression/WALCompressionTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/compression/WALCompressionTest.java
@@ -53,10 +53,13 @@ import org.junit.Test;
 import java.io.BufferedInputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
+import java.io.EOFException;
 import java.io.File;
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
 import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -113,6 +116,76 @@ public class WALCompressionTest {
     testSkipToGivenPosition();
   }
 
+  @Test
+  public void testReleaseReadBuffersPreservesPositionAcrossSegmentsAndSeeks() 
throws Exception {
+    byte[] expected = new byte[24];
+    for (int i = 0; i < expected.length; i++) {
+      expected[i] = (byte) i;
+    }
+    for (CompressionType compression :
+        new CompressionType[] {CompressionType.UNCOMPRESSED, 
CompressionType.LZ4}) {
+      
IoTDBDescriptor.getInstance().getConfig().setWALCompressionAlgorithm(compression);
+      try (WALWriter writer = new WALWriter(walFile, WALFileVersion.V3)) {
+        writer.setCompressedByteBuffer(ByteBuffer.allocate(128));
+        for (int start = 0; start < expected.length; start += 8) {
+          ByteBuffer segment = ByteBuffer.allocate(8);
+          segment.put(expected, start, 8);
+          writer.write(segment);
+        }
+      }
+      try (WALInputStream stream = new WALInputStream(walFile)) {
+        Assert.assertEquals(0, stream.read());
+        int available = stream.available();
+        stream.releaseReadBuffers();
+        stream.releaseReadBuffers();
+        Assert.assertEquals(available, stream.available());
+        Assert.assertEquals(1, stream.getLogicalReadOffset());
+        byte[] crossSegment = new byte[10];
+        stream.read(crossSegment);
+        Assert.assertArrayEquals(java.util.Arrays.copyOfRange(expected, 1, 
11), crossSegment);
+        stream.releaseReadBuffers();
+        stream.skipToGivenLogicalPosition(4);
+        stream.releaseReadBuffers();
+        ByteBuffer restOfSegment = ByteBuffer.allocate(4);
+        stream.read(restOfSegment);
+        Assert.assertArrayEquals(
+            java.util.Arrays.copyOfRange(expected, 4, 8), 
restOfSegment.array());
+        // A pause exactly at the segment boundary must load the next segment 
without replaying it.
+        stream.releaseReadBuffers();
+        Assert.assertEquals(8, stream.read());
+        stream.releaseReadBuffers();
+        ByteBuffer tail = ByteBuffer.allocate(15);
+        stream.read(tail);
+        Assert.assertArrayEquals(java.util.Arrays.copyOfRange(expected, 9, 
24), tail.array());
+      }
+      Files.delete(walFile.toPath());
+    }
+  }
+
+  @Test
+  public void testReleasedReadBufferDetectsShortenedSegment() throws Exception 
{
+    try (WALWriter writer = new WALWriter(walFile, WALFileVersion.V3)) {
+      ByteBuffer segment = ByteBuffer.allocate(8);
+      segment.putLong(42L);
+      writer.write(segment);
+    }
+    try (WALInputStream stream = new WALInputStream(walFile)) {
+      for (int i = 0; i < 3; i++) {
+        stream.read();
+      }
+      stream.releaseReadBuffers();
+      try (FileChannel channel = FileChannel.open(walFile.toPath(), 
StandardOpenOption.WRITE)) {
+        channel.position(WALFileVersion.V3.getVersionBytes().length);
+        ByteBuffer header = ByteBuffer.allocate(Byte.BYTES + Integer.BYTES);
+        header.put(CompressionType.UNCOMPRESSED.serialize()).putInt(2).flip();
+        while (header.hasRemaining()) {
+          channel.write(header);
+        }
+      }
+      Assert.assertThrows(EOFException.class, stream::read);
+    }
+  }
+
   @Test
   public void testSkipToGivenPositionWithoutCompression()
       throws NoSuchFieldException,
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReaderTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReaderTest.java
index 755964e1d25..29a6dfdd56c 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReaderTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/io/ProgressWALReaderTest.java
@@ -19,19 +19,94 @@
 
 package org.apache.iotdb.db.storageengine.dataregion.wal.io;
 
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.storageengine.dataregion.wal.WALTestUtils;
+
+import org.apache.tsfile.file.metadata.enums.CompressionType;
 import org.junit.Test;
 
 import java.io.File;
+import java.lang.management.BufferPoolMXBean;
+import java.lang.management.ManagementFactory;
 import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 public class ProgressWALReaderTest {
 
+  @Test
+  public void testPausedReadersReleaseDirectMemoryAndResumeWithinSegment() 
throws Exception {
+    Path dir = Files.createTempDirectory("progress-wal-reader-buffers");
+    File walFile = dir.resolve("test.wal").toFile();
+    CompressionType originalCompression =
+        IoTDBDescriptor.getInstance().getConfig().getWALCompressionAlgorithm();
+    long originalMinCompressionSize = WALTestUtils.getMinCompressionSize();
+    List<ProgressWALReader> readers = new ArrayList<>();
+    try {
+      WALTestUtils.setMinCompressionSize(0);
+      for (CompressionType compression :
+          new CompressionType[] {CompressionType.UNCOMPRESSED, 
CompressionType.LZ4}) {
+        
IoTDBDescriptor.getInstance().getConfig().setWALCompressionAlgorithm(compression);
+        final int entrySize = 1024 * 1024;
+        byte[] first = new byte[entrySize];
+        byte[] second = new byte[entrySize];
+        Arrays.fill(first, (byte) 11);
+        Arrays.fill(second, (byte) 22);
+        ByteBuffer segment = ByteBuffer.allocate(entrySize * 2);
+        segment.put(first).put(second);
+        WALMetaData metadata = new WALMetaData();
+        metadata.add(entrySize, 10, 1, 1000, 1, 10);
+        metadata.add(entrySize, 11, 1, 2000, 1, 11);
+        try (WALWriter writer = new WALWriter(walFile, WALFileVersion.V3)) {
+          
writer.setCompressedByteBuffer(ByteBuffer.allocate(segment.capacity() * 2));
+          writer.write(segment, metadata);
+        }
+        BufferPoolMXBean directPool =
+            
ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).stream()
+                .filter(pool -> "direct".equals(pool.getName()))
+                .findFirst()
+                .get();
+        long initialDirectMemory = directPool.getMemoryUsed();
+        for (int i = 0; i < 32; i++) {
+          ProgressWALReader reader = new ProgressWALReader(walFile);
+          readers.add(reader);
+          assertArrayEquals(first, reader.next().array());
+          reader.releaseReadBuffers();
+          reader.releaseReadBuffers();
+        }
+        // All readers remain open halfway through the same segment. Their 
retained direct memory
+        // must not grow with the number of subscriptions (64 MiB without 
releasing the buffers).
+        assertTrue(directPool.getMemoryUsed() - initialDirectMemory < 
entrySize);
+        for (ProgressWALReader reader : readers) {
+          assertArrayEquals(second, reader.next().array());
+          assertEquals(1, reader.getCurrentEntryIndex());
+          assertEquals(11, reader.getCurrentEntryLocalSeq());
+          assertFalse(reader.hasNext());
+          reader.releaseReadBuffers();
+          reader.close();
+        }
+        readers.clear();
+        Files.delete(walFile.toPath());
+      }
+    } finally {
+      for (ProgressWALReader reader : readers) {
+        reader.close();
+      }
+      
IoTDBDescriptor.getInstance().getConfig().setWALCompressionAlgorithm(originalCompression);
+      WALTestUtils.setMinCompressionSize(originalMinCompressionSize);
+      Files.deleteIfExists(walFile.toPath());
+      Files.deleteIfExists(dir);
+    }
+  }
+
   @Test
   public void testReadWriterProgressMetadataFromV3Wal() throws Exception {
     Path dir = Files.createTempDirectory("progress-wal-reader");
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
index a4bfb29304d..5202b0ef1b7 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
@@ -94,6 +94,9 @@ public class ProgressWALIteratorTest {
           assertTrue(iterator.hasYieldedScan());
           assertFalse(iterator.hasBufferedNext());
           assertFalse(iterator.hasIncompleteScan());
+          iterator.releaseReadBuffers();
+          // Releasing the segment must preserve both the physical cursor and 
request fragments.
+          iterator.releaseReadBuffers();
           // Neither a skipped prefix nor an unfinished follower request is a 
completed delivery.
           assertFalse(iterator.hasNext(budget));
         }
diff --git 
a/iotdb-core/metrics/interface/src/main/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporter.java
 
b/iotdb-core/metrics/interface/src/main/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporter.java
index 00ae6c6b988..30ce9573637 100644
--- 
a/iotdb-core/metrics/interface/src/main/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporter.java
+++ 
b/iotdb-core/metrics/interface/src/main/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporter.java
@@ -46,6 +46,7 @@ import io.netty.handler.ssl.SslContextBuilder;
 import io.netty.util.concurrent.GlobalEventExecutor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 import reactor.netty.DisposableServer;
 import reactor.netty.http.server.HttpServer;
@@ -77,6 +78,7 @@ public class PrometheusReporter implements Reporter {
   private static final MetricConfig METRIC_CONFIG =
       MetricConfigDescriptor.getInstance().getMetricConfig();
   private static final long PROMETHEUS_DEFAULT_SCRAPE_INTERVAL_SECONDS = 15;
+  private static final int PROMETHEUS_RESPONSE_CHUNK_SIZE = 8 * 1024;
   private final AbstractMetricManager metricManager;
   private final Supplier<ScheduledExecutorService> 
snapshotUpdateExecutorSupplier;
   private volatile ScheduledExecutorService snapshotUpdateExecutor;
@@ -148,7 +150,8 @@ public class PrometheusReporter implements Reporter {
                                     ? getMetricsSnapshot()
                                     : scrape();
                             return res.header(HttpHeaderNames.CONTENT_TYPE, 
"text/plain")
-                                .sendString(Mono.just(metrics));
+                                .sendString(
+                                    splitMetricsIntoChunks(metrics), 
StandardCharsets.UTF_8);
                           }));
       if (METRIC_CONFIG.isEnableSSL()) {
         SslContext sslContext;
@@ -189,6 +192,27 @@ public class PrometheusReporter implements Reporter {
     return true;
   }
 
+  static Flux<String> splitMetricsIntoChunks(final String metrics) {
+    // Encoding one snapshot into a single direct ByteBuf requires a 
contiguous allocation sized
+    // for every series. Generate bounded chunks on demand so slow scrapes do 
not pin that buffer.
+    return Flux.generate(
+        () -> 0,
+        (start, sink) -> {
+          if (start >= metrics.length()) {
+            sink.complete();
+            return start;
+          }
+          int end = start + Math.min(PROMETHEUS_RESPONSE_CHUNK_SIZE, 
metrics.length() - start);
+          if (end < metrics.length()
+              && Character.isHighSurrogate(metrics.charAt(end - 1))
+              && Character.isLowSurrogate(metrics.charAt(end))) {
+            end--;
+          }
+          sink.next(metrics.substring(start, end));
+          return end;
+        });
+  }
+
   @SuppressWarnings("unsafeThreadSchedule")
   private void startSnapshotUpdater() {
     // Keep metric collection off Reactor HTTP threads and avoid overlapping 
scrapes.
diff --git 
a/iotdb-core/metrics/interface/src/test/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporterTest.java
 
b/iotdb-core/metrics/interface/src/test/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporterTest.java
index 7072777ac66..5b19d7f5f31 100644
--- 
a/iotdb-core/metrics/interface/src/test/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporterTest.java
+++ 
b/iotdb-core/metrics/interface/src/test/java/org/apache/iotdb/metrics/reporter/prometheus/PrometheusReporterTest.java
@@ -24,7 +24,13 @@ import 
org.apache.iotdb.metrics.config.MetricConfigDescriptor;
 import org.apache.iotdb.metrics.impl.DoNothingMetricManager;
 
 import org.junit.Test;
+import reactor.netty.DisposableServer;
 
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Executors;
@@ -72,4 +78,55 @@ public class PrometheusReporterTest {
       executors.forEach(ScheduledExecutorService::shutdownNow);
     }
   }
+
+  @Test
+  public void testMetricsResponseChunksPreserveContentAndSurrogatePairs() {
+    String metrics = "x".repeat(8 * 1024 - 1) + "\uD83D\uDE00" + "y".repeat(8 
* 1024 + 1);
+
+    List<String> chunks = 
PrometheusReporter.splitMetricsIntoChunks(metrics).collectList().block();
+
+    assertEquals(metrics, String.join("", chunks));
+    assertTrue(chunks.stream().allMatch(chunk -> chunk.length() <= 8 * 1024));
+    assertEquals(8 * 1024 - 1, chunks.get(0).length());
+    assertTrue(chunks.get(1).startsWith("\uD83D\uDE00"));
+  }
+
+  @Test
+  public void testLargeMetricsSnapshotCanBeScraped() throws Exception {
+    MetricConfig metricConfig = 
MetricConfigDescriptor.getInstance().getMetricConfig();
+    boolean originalAsyncUpdate = 
metricConfig.isPrometheusReporterAsyncUpdate();
+    Integer originalPort = metricConfig.getPrometheusReporterPort();
+    metricConfig.setPrometheusReporterAsyncUpdate(true);
+    metricConfig.setPrometheusReporterPort(0);
+    PrometheusReporter reporter = new PrometheusReporter(new 
DoNothingMetricManager());
+    HttpURLConnection connection = null;
+    try {
+      assertTrue(reporter.start());
+      // Also run this regression with -XX:MaxDirectMemorySize=32m: a single 
response buffer for
+      // this snapshot cannot fit, whereas streaming it requires only bounded 
direct memory.
+      String metrics = "x".repeat(8 * 1024 - 1) + "\uD83D\uDE00" + 
"y".repeat(24 * 1024 * 1024);
+      Field snapshotField = 
PrometheusReporter.class.getDeclaredField("metricsSnapshot");
+      snapshotField.setAccessible(true);
+      snapshotField.set(reporter, metrics);
+      Field serverField = 
PrometheusReporter.class.getDeclaredField("httpServer");
+      serverField.setAccessible(true);
+      int port = ((DisposableServer) serverField.get(reporter)).port();
+      connection =
+          (HttpURLConnection)
+              URI.create("http://127.0.0.1:"; + port + 
"/metrics").toURL().openConnection();
+      connection.setConnectTimeout(5_000);
+      connection.setReadTimeout(10_000);
+      assertEquals(200, connection.getResponseCode());
+      try (InputStream response = connection.getInputStream()) {
+        assertEquals(metrics, new String(response.readAllBytes(), 
StandardCharsets.UTF_8));
+      }
+    } finally {
+      if (connection != null) {
+        connection.disconnect();
+      }
+      reporter.stop();
+      metricConfig.setPrometheusReporterAsyncUpdate(originalAsyncUpdate);
+      metricConfig.setPrometheusReporterPort(originalPort);
+    }
+  }
 }

Reply via email to