This is an automated email from the ASF dual-hosted git repository.
JackieTien97 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 fd398988672 Bound PartitionInfo snapshot buffers via
config_node_snapshot_buffer_size_max (#18580)
fd398988672 is described below
commit fd398988672d84d12163103b251a0cea6891a07e
Author: Yongzao <[email protected]>
AuthorDate: Fri Sep 4 16:07:05 2026 +0800
Bound PartitionInfo snapshot buffers via
config_node_snapshot_buffer_size_max (#18580)
---
.../iotdb/confignode/conf/ConfigNodeConfig.java | 20 ++
.../confignode/conf/ConfigNodeDescriptor.java | 8 +
.../persistence/partition/PartitionInfo.java | 23 +-
.../confignode/conf/ConfigNodeConfigTest.java | 44 +++
.../confignode/persistence/PartitionInfoTest.java | 65 +++++
.../resources/conf/edge/iotdb-system.properties | 5 +
.../conf/iotdb-system.properties.template | 5 +
.../apache/iotdb/commons/i18n/CommonMessages.java | 3 +
.../apache/iotdb/commons/i18n/CommonMessages.java | 3 +
.../snapshot/ReusableBufferedOutputStream.java | 134 +++++++++
.../commons/snapshot/SnapshotStreamFactory.java | 130 +++++++++
.../snapshot/SnapshotStreamFactoryTest.java | 307 +++++++++++++++++++++
12 files changed, 737 insertions(+), 10 deletions(-)
diff --git
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeConfig.java
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeConfig.java
index f49525ea7da..c1baa98209c 100644
---
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeConfig.java
+++
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeConfig.java
@@ -26,6 +26,7 @@ import org.apache.iotdb.common.rpc.thrift.TEndPoint;
import
org.apache.iotdb.commons.client.property.ClientPoolProperty.DefaultProperty;
import org.apache.iotdb.commons.conf.CommonDescriptor;
import org.apache.iotdb.commons.conf.IoTDBConstant;
+import org.apache.iotdb.commons.i18n.CommonMessages;
import org.apache.iotdb.confignode.i18n.ConfigNodeMessages;
import org.apache.iotdb.confignode.manager.load.balancer.RegionBalancer;
import
org.apache.iotdb.confignode.manager.load.balancer.router.leader.AbstractLeaderBalancer;
@@ -245,6 +246,9 @@ public class ConfigNodeConfig {
private long configNodeRatisConsensusLogAppenderBufferSize = 16 * 1024 *
1024L;
private long schemaRegionRatisConsensusLogAppenderBufferSize = 16 * 1024 *
1024L;
+ /** Max size (in bytes) of the in-memory buffer used when taking/loading
ConfigNode snapshots. */
+ private long configNodeSnapshotBufferSizeMax = 4 * 1024 * 1024L;
+
/**
* RatisConsensus protocol, trigger a snapshot when
ratis_snapshot_trigger_threshold logs are
* written.
@@ -973,6 +977,22 @@ public class ConfigNodeConfig {
schemaRegionRatisConsensusLogAppenderBufferSize;
}
+ public long getConfigNodeSnapshotBufferSizeMax() {
+ return configNodeSnapshotBufferSizeMax;
+ }
+
+ public void setConfigNodeSnapshotBufferSizeMax(long
configNodeSnapshotBufferSizeMax) {
+ if (configNodeSnapshotBufferSizeMax > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException(
+ String.format(
+ CommonMessages
+
.EXCEPTION_SNAPSHOT_BUFFER_SIZE_MUST_NOT_EXCEED_ARG_BYTES_BUT_WAS_ARG_D1DA6F7E,
+ Integer.MAX_VALUE,
+ configNodeSnapshotBufferSizeMax));
+ }
+ this.configNodeSnapshotBufferSizeMax = configNodeSnapshotBufferSizeMax;
+ }
+
public long getSchemaRegionRatisSnapshotTriggerThreshold() {
return schemaRegionRatisSnapshotTriggerThreshold;
}
diff --git
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeDescriptor.java
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeDescriptor.java
index 16dfd4150f1..b4e8b6f9d89 100644
---
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeDescriptor.java
+++
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/conf/ConfigNodeDescriptor.java
@@ -28,6 +28,7 @@ import org.apache.iotdb.commons.exception.BadNodeUrlException;
import org.apache.iotdb.commons.log.LoggerPeriodicalLogReducer;
import org.apache.iotdb.commons.pipe.config.PipeDescriptor;
import org.apache.iotdb.commons.schema.SchemaConstant;
+import org.apache.iotdb.commons.snapshot.SnapshotStreamFactory;
import org.apache.iotdb.commons.utils.NodeUrlUtils;
import org.apache.iotdb.confignode.i18n.ConfigNodeMessages;
import org.apache.iotdb.confignode.manager.load.balancer.RegionBalancer;
@@ -403,6 +404,13 @@ public class ConfigNodeDescriptor {
loadRatisConsensusConfig(properties);
loadCQConfig(properties);
+
+ conf.setConfigNodeSnapshotBufferSizeMax(
+ Long.parseLong(
+ properties.getProperty(
+ "config_node_snapshot_buffer_size_max",
+ String.valueOf(conf.getConfigNodeSnapshotBufferSizeMax()))));
+
SnapshotStreamFactory.setBufferSizeMax(conf.getConfigNodeSnapshotBufferSizeMax());
}
private void loadRatisConsensusConfig(TrimProperties properties) {
diff --git
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/partition/PartitionInfo.java
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/partition/PartitionInfo.java
index 8b2079b581d..71fef7c21d0 100644
---
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/partition/PartitionInfo.java
+++
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/partition/PartitionInfo.java
@@ -30,6 +30,7 @@ import org.apache.iotdb.commons.partition.DataPartitionTable;
import org.apache.iotdb.commons.partition.SchemaPartitionTable;
import org.apache.iotdb.commons.schema.table.Audit;
import org.apache.iotdb.commons.snapshot.SnapshotProcessor;
+import org.apache.iotdb.commons.snapshot.SnapshotStreamFactory;
import org.apache.iotdb.commons.utils.PathUtils;
import
org.apache.iotdb.confignode.consensus.request.read.partition.CountTimeSlotListPlan;
import
org.apache.iotdb.confignode.consensus.request.read.partition.GetDataPartitionPlan;
@@ -80,11 +81,11 @@ import org.apache.tsfile.utils.ReadWriteIOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.io.BufferedInputStream;
-import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.BitSet;
@@ -119,9 +120,6 @@ public class PartitionInfo implements SnapshotProcessor {
private static final Logger LOGGER =
LoggerFactory.getLogger(PartitionInfo.class);
- // Allocate 8MB buffer for load snapshot of PartitionInfo
- private static final int PARTITION_TABLE_BUFFER_SIZE = 32 * 1024 * 1024;
-
/** For Cluster Partition. */
// For allocating Regions
private final AtomicInteger nextRegionGroupId;
@@ -993,9 +991,11 @@ public class PartitionInfo implements SnapshotProcessor {
// snapshot operation.
File tmpFile = new File(snapshotFile.getAbsolutePath() + "-" +
UUID.randomUUID());
+ // The write buffer is bounded by config_node_snapshot_buffer_size_max, so
a small partition
+ // table no longer allocates a fixed 32MB buffer per snapshot.
try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile);
- BufferedOutputStream bufferedOutputStream =
- new BufferedOutputStream(fileOutputStream,
PARTITION_TABLE_BUFFER_SIZE);
+ OutputStream bufferedOutputStream =
+ SnapshotStreamFactory.createOutputStream(fileOutputStream);
TIOStreamTransport tioStreamTransport = new
TIOStreamTransport(bufferedOutputStream)) {
TProtocol protocol = new TBinaryProtocol(tioStreamTransport);
@@ -1050,9 +1050,12 @@ public class PartitionInfo implements SnapshotProcessor {
return;
}
- try (final BufferedInputStream fileInputStream =
- new BufferedInputStream(
- Files.newInputStream(snapshotFile.toPath()),
PARTITION_TABLE_BUFFER_SIZE);
+ // The read buffer is sized from the file size and capped by
+ // config_node_snapshot_buffer_size_max,
+ // so loading a snapshot never allocates more than the configured cap.
+ try (final InputStream fileInputStream =
+ SnapshotStreamFactory.createInputStream(
+ Files.newInputStream(snapshotFile.toPath()),
snapshotFile.length());
final TIOStreamTransport tioStreamTransport = new
TIOStreamTransport(fileInputStream)) {
final TProtocol protocol = new TBinaryProtocol(tioStreamTransport);
// before restoring a snapshot, clear all old data
diff --git
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/conf/ConfigNodeConfigTest.java
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/conf/ConfigNodeConfigTest.java
new file mode 100644
index 00000000000..da016c5e163
--- /dev/null
+++
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/conf/ConfigNodeConfigTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.iotdb.confignode.conf;
+
+import org.apache.iotdb.commons.snapshot.SnapshotStreamFactory;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ConfigNodeConfigTest {
+
+ @Test
+ public void testSnapshotBufferSizeMaxDefault() {
+ final ConfigNodeConfig configNodeConfig = new ConfigNodeConfig();
+ // The code default must stay aligned with SnapshotStreamFactory's default
cap.
+ Assert.assertEquals(
+ SnapshotStreamFactory.DEFAULT_BUFFER_SIZE_MAX,
+ configNodeConfig.getConfigNodeSnapshotBufferSizeMax());
+
+ configNodeConfig.setConfigNodeSnapshotBufferSizeMax(256 * 1024L);
+ Assert.assertEquals(256 * 1024L,
configNodeConfig.getConfigNodeSnapshotBufferSizeMax());
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () -> configNodeConfig.setConfigNodeSnapshotBufferSizeMax((long)
Integer.MAX_VALUE + 1));
+ Assert.assertEquals(256 * 1024L,
configNodeConfig.getConfigNodeSnapshotBufferSizeMax());
+ }
+}
diff --git
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/PartitionInfoTest.java
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/PartitionInfoTest.java
index afccb0c0eba..21744963066 100644
---
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/PartitionInfoTest.java
+++
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/PartitionInfoTest.java
@@ -29,6 +29,7 @@ import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
import org.apache.iotdb.commons.partition.DataPartitionTable;
import org.apache.iotdb.commons.partition.SchemaPartitionTable;
import org.apache.iotdb.commons.partition.SeriesPartitionTable;
+import org.apache.iotdb.commons.snapshot.SnapshotStreamFactory;
import org.apache.iotdb.confignode.consensus.request.ConfigPhysicalPlanType;
import
org.apache.iotdb.confignode.consensus.request.read.region.GetRegionInfoListPlan;
import
org.apache.iotdb.confignode.consensus.request.write.database.DatabaseSchemaPlan;
@@ -66,6 +67,8 @@ public class PartitionInfoTest {
private static PartitionInfo partitionInfo;
private static final File snapshotDir = new File(BASE_OUTPUT_PATH,
"snapshot");
+ private long originalSnapshotBufferSizeMax;
+
public enum testFlag {
DataPartition(20),
SchemaPartition(30);
@@ -87,6 +90,10 @@ public class PartitionInfoTest {
if (!snapshotDir.exists()) {
snapshotDir.mkdirs();
}
+ // Run the snapshot round-trips of this class with a small buffer cap,
proving that snapshot
+ // correctness does not depend on a large fixed buffer.
+ originalSnapshotBufferSizeMax = SnapshotStreamFactory.getBufferSizeMax();
+ SnapshotStreamFactory.setBufferSizeMax(64 * 1024);
}
@After
@@ -95,6 +102,7 @@ public class PartitionInfoTest {
if (snapshotDir.exists()) {
FileUtils.deleteDirectory(snapshotDir);
}
+ SnapshotStreamFactory.setBufferSizeMax(originalSnapshotBufferSizeMax);
}
@Test
@@ -152,6 +160,63 @@ public class PartitionInfoTest {
Assert.assertEquals(partitionInfo, partitionInfo1);
}
+ @Test
+ public void testSnapshotWithWriteBufferSmallerThanSnapshot() throws
TException, IOException {
+ partitionInfo.generateNextRegionGroupId();
+
+ // Set StorageGroup
+ partitionInfo.createDatabase(
+ new DatabaseSchemaPlan(
+ ConfigPhysicalPlanType.CreateDatabase, new
TDatabaseSchema("root.test")));
+
+ // Create a SchemaRegion
+ CreateRegionGroupsPlan createRegionGroupsReq = new
CreateRegionGroupsPlan();
+ final TRegionReplicaSet schemaRegionReplicaSet =
+ generateTRegionReplicaSet(
+ testFlag.SchemaPartition.getFlag(),
+ generateTConsensusGroupId(
+ testFlag.SchemaPartition.getFlag(),
TConsensusGroupType.SchemaRegion));
+ createRegionGroupsReq.addRegionGroup("root.test", schemaRegionReplicaSet);
+ partitionInfo.createRegionGroups(createRegionGroupsReq);
+
+ // Create a DataRegion
+ createRegionGroupsReq = new CreateRegionGroupsPlan();
+ final TRegionReplicaSet dataRegionReplicaSet =
+ generateTRegionReplicaSet(
+ testFlag.DataPartition.getFlag(),
+ generateTConsensusGroupId(
+ testFlag.DataPartition.getFlag(),
TConsensusGroupType.DataRegion));
+ createRegionGroupsReq.addRegionGroup("root.test", dataRegionReplicaSet);
+ partitionInfo.createRegionGroups(createRegionGroupsReq);
+
+ // Create a data partition table far larger than the 64KB write buffer
configured in setup(),
+ // so the buffered snapshot stream must flush and wrap around many times.
+ final CreateDataPartitionPlan createDataPartitionPlan = new
CreateDataPartitionPlan();
+ final Map<String, DataPartitionTable> dataPartitionMap = new HashMap<>();
+ final Map<TSeriesPartitionSlot, SeriesPartitionTable> slotInfo = new
HashMap<>();
+ final TConsensusGroupId dataRegionId = dataRegionReplicaSet.getRegionId();
+ for (int seriesSlot = 0; seriesSlot < 2000; seriesSlot++) {
+ final Map<TTimePartitionSlot, List<TConsensusGroupId>> relationInfo =
new HashMap<>();
+ for (int timeSlot = 0; timeSlot < 8; timeSlot++) {
+ relationInfo.put(new TTimePartitionSlot(timeSlot),
Collections.singletonList(dataRegionId));
+ }
+ slotInfo.put(new TSeriesPartitionSlot(seriesSlot), new
SeriesPartitionTable(relationInfo));
+ }
+ dataPartitionMap.put("root.test", new DataPartitionTable(slotInfo));
+ createDataPartitionPlan.setAssignedDataPartition(dataPartitionMap);
+ partitionInfo.createDataPartition(createDataPartitionPlan);
+
+ Assert.assertTrue(partitionInfo.processTakeSnapshot(snapshotDir));
+
+ // The snapshot must actually be larger than the 64KB buffer for this test
to be meaningful.
+ final File snapshotFile = new File(snapshotDir, "partition_info.bin");
+ Assert.assertTrue(snapshotFile.length() > 64 * 1024);
+
+ final PartitionInfo partitionInfo1 = new PartitionInfo();
+ partitionInfo1.processLoadSnapshot(snapshotDir);
+ Assert.assertEquals(partitionInfo, partitionInfo1);
+ }
+
@Test
public void testGetRegionType() {
diff --git
a/iotdb-core/node-commons/src/assembly/resources/conf/edge/iotdb-system.properties
b/iotdb-core/node-commons/src/assembly/resources/conf/edge/iotdb-system.properties
index a4686233b71..01cac68af52 100644
---
a/iotdb-core/node-commons/src/assembly/resources/conf/edge/iotdb-system.properties
+++
b/iotdb-core/node-commons/src/assembly/resources/conf/edge/iotdb-system.properties
@@ -169,6 +169,11 @@ schema_region_ratis_preserve_logs_num_when_purge=200
config_node_ratis_periodic_snapshot_interval=1800
schema_region_ratis_periodic_snapshot_interval=1800
+# Cap the snapshot I/O buffer of the ConfigNode at 8KB (stock default: 4MB).
+# Snapshots are tiny on an edge node, so this keeps their transient heap
+# allocation negligible.
+config_node_snapshot_buffer_size_max=8192
+
# ---- realtime pipe sync out of the box ----
# The pipe memory pool is 10% of the heap (~22MB at the default 224M budget),
# while the stock pipe memory estimates are sized for datacenter nodes: each
diff --git
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
index e325ff42573..d2e00d2181d 100644
---
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
+++
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
@@ -2123,6 +2123,11 @@ config_node_ratis_snapshot_trigger_threshold=400000
schema_region_ratis_snapshot_trigger_threshold=400000
data_region_ratis_snapshot_trigger_threshold=400000
+# max size (in byte) of the in-memory buffer used when taking/loading
ConfigNode snapshot files
+# effectiveMode: restart
+# Datatype: long
+config_node_snapshot_buffer_size_max=4194304
+
# allow flushing Raft Log asynchronously
# effectiveMode: restart
# Datatype: Boolean
diff --git
a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/CommonMessages.java
b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/CommonMessages.java
index 89c4600776b..a6b958fd142 100644
---
a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/CommonMessages.java
+++
b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/CommonMessages.java
@@ -342,4 +342,7 @@ public final class CommonMessages {
EXCEPTION_XCORR_REQUIRES_EXACTLY_TWO_CALCULATION_COLUMNS_BUT_FOUND_ARG_2FF8EB0C
=
"XCorr requires exactly two calculation columns, but found %d.";
public static final String EXCEPTION_COLUMN_LACK_OF_NAME = "the column in
table lack of the name";
+ public static final String
+
EXCEPTION_SNAPSHOT_BUFFER_SIZE_MUST_NOT_EXCEED_ARG_BYTES_BUT_WAS_ARG_D1DA6F7E =
+ "Snapshot buffer size must not exceed %d bytes, but was %d.";
}
diff --git
a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/CommonMessages.java
b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/CommonMessages.java
index f854ddb77f3..c3af2dbe7e2 100644
---
a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/CommonMessages.java
+++
b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/CommonMessages.java
@@ -238,4 +238,7 @@ public final class CommonMessages {
EXCEPTION_XCORR_REQUIRES_EXACTLY_TWO_CALCULATION_COLUMNS_BUT_FOUND_ARG_2FF8EB0C
=
"XCorr 要求必须正好有两列计算列,但实际找到 %d 列。";
public static final String EXCEPTION_COLUMN_LACK_OF_NAME = "表参数中列缺少名字";
+ public static final String
+
EXCEPTION_SNAPSHOT_BUFFER_SIZE_MUST_NOT_EXCEED_ARG_BYTES_BUT_WAS_ARG_D1DA6F7E =
+ "快照缓冲区大小不得超过 %d 字节,但实际为 %d。";
}
diff --git
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/ReusableBufferedOutputStream.java
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/ReusableBufferedOutputStream.java
new file mode 100644
index 00000000000..e18922cac06
--- /dev/null
+++
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/ReusableBufferedOutputStream.java
@@ -0,0 +1,134 @@
+/*
+ * 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.iotdb.commons.snapshot;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Objects;
+
+/**
+ * A {@link java.io.BufferedOutputStream} variant whose backing byte array is
borrowed from and
+ * returned to {@link SnapshotStreamFactory}'s per-thread pool instead of
being freshly allocated
+ * per stream. Behavior follows {@link java.io.BufferedOutputStream}: {@link
#close()} propagates a
+ * flush failure while still closing the underlying stream.
+ *
+ * <p>Writes larger than the buffer bypass it entirely, exactly like the JDK
implementation would
+ * after flushing.
+ */
+final class ReusableBufferedOutputStream extends OutputStream {
+
+ private final OutputStream out;
+ private final int bufferSize;
+
+ /** Lazily borrowed from {@link SnapshotStreamFactory} on the first write. */
+ private byte[] buffer;
+
+ private int count;
+
+ private boolean closed;
+
+ ReusableBufferedOutputStream(final OutputStream out, final int bufferSize) {
+ this.out = Objects.requireNonNull(out);
+ this.bufferSize = bufferSize;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ ensureOpen();
+ ensureBuffer();
+ if (count >= buffer.length) {
+ flushBuffer();
+ }
+ buffer[count++] = (byte) b;
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws
IOException {
+ ensureOpen();
+ Objects.checkFromIndexSize(off, len, b.length);
+ if (len == 0) {
+ return;
+ }
+ if (buffer == null && len >= bufferSize) {
+ // Large write while the buffer has not even been allocated: skip the
buffer entirely
+ // instead of allocating it just to flush it immediately.
+ out.write(b, off, len);
+ return;
+ }
+ ensureBuffer();
+ if (len >= buffer.length) {
+ flushBuffer();
+ out.write(b, off, len);
+ return;
+ }
+ if (len > buffer.length - count) {
+ flushBuffer();
+ }
+ System.arraycopy(b, off, buffer, count, len);
+ count += len;
+ }
+
+ @Override
+ public void flush() throws IOException {
+ ensureOpen();
+ flushBuffer();
+ out.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (closed) {
+ return;
+ }
+ try (final OutputStream outputStream = out) {
+ flush();
+ } finally {
+ closed = true;
+ releaseBuffer();
+ }
+ }
+
+ private void ensureBuffer() {
+ if (buffer == null) {
+ buffer = SnapshotStreamFactory.acquireBuffer(bufferSize);
+ }
+ }
+
+ private void ensureOpen() throws IOException {
+ if (closed) {
+ throw new IOException();
+ }
+ }
+
+ private void flushBuffer() throws IOException {
+ if (count > 0) {
+ out.write(buffer, 0, count);
+ count = 0;
+ }
+ }
+
+ private void releaseBuffer() {
+ count = 0;
+ if (buffer != null) {
+ SnapshotStreamFactory.releaseBuffer(buffer);
+ buffer = null;
+ }
+ }
+}
diff --git
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactory.java
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactory.java
new file mode 100644
index 00000000000..ceb1b008e28
--- /dev/null
+++
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactory.java
@@ -0,0 +1,130 @@
+/*
+ * 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.iotdb.commons.snapshot;
+
+import org.apache.iotdb.commons.i18n.CommonMessages;
+
+import java.io.BufferedInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.ref.SoftReference;
+
+/**
+ * Creates the buffered streams used to write and read ConfigNode snapshot
files.
+ *
+ * <p>The buffer size is bounded by {@link #bufferSizeMax}, which is
configurable through {@code
+ * config_node_snapshot_buffer_size_max} (0 disables buffering). This replaces
the previous fixed
+ * 32MB buffer of {@code PartitionInfo}: memory-constrained deployments can
lower the cap, while the
+ * default keeps snapshot I/O fast for large partition tables. Write buffers
are pooled per thread
+ * through {@link SoftReference}s, so consecutive snapshots on the same thread
reuse the array
+ * instead of re-allocating it.
+ */
+public final class SnapshotStreamFactory {
+
+ /** Default upper bound of a snapshot stream buffer, 4MB. */
+ public static final long DEFAULT_BUFFER_SIZE_MAX = 4 * 1024 * 1024L;
+
+ private static volatile long bufferSizeMax = DEFAULT_BUFFER_SIZE_MAX;
+
+ /** Thread-local pool of reusable write buffers, kept only as long as GC
allows. */
+ private static final ThreadLocal<SoftReference<byte[]>> WRITE_BUFFER_POOL =
+ ThreadLocal.withInitial(() -> new SoftReference<>(null));
+
+ private SnapshotStreamFactory() {
+ // Utility class
+ }
+
+ /**
+ * Set the upper bound of snapshot stream buffers, in bytes. Values below or
equal to zero disable
+ * buffering entirely (the raw stream is returned unchanged). Thread-safe;
takes effect on the
+ * next stream creation.
+ */
+ public static void setBufferSizeMax(final long sizeInBytes) {
+ if (sizeInBytes > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException(
+ String.format(
+ CommonMessages
+
.EXCEPTION_SNAPSHOT_BUFFER_SIZE_MUST_NOT_EXCEED_ARG_BYTES_BUT_WAS_ARG_D1DA6F7E,
+ Integer.MAX_VALUE,
+ sizeInBytes));
+ }
+ bufferSizeMax = Math.max(0L, sizeInBytes);
+ }
+
+ public static long getBufferSizeMax() {
+ return bufferSizeMax;
+ }
+
+ /**
+ * Wrap {@code raw} with a buffered output stream whose buffer is at most
{@link #bufferSizeMax}
+ * bytes. The buffer is reusable, so the same thread writing several
snapshots does not repeatedly
+ * allocate it.
+ *
+ * @param raw the raw stream to buffer
+ * @return a buffered stream, or {@code raw} itself if buffering is disabled
+ */
+ public static OutputStream createOutputStream(final OutputStream raw) {
+ final int bufferSize = (int) bufferSizeMax;
+ return bufferSize <= 0 ? raw : new ReusableBufferedOutputStream(raw,
bufferSize);
+ }
+
+ /**
+ * Wrap {@code raw} with a buffered input stream whose buffer is sized to at
most {@code
+ * fileSize}, capped by {@link #bufferSizeMax}. Reading never allocates more
than the configured
+ * cap, no matter how large the snapshot file is.
+ *
+ * @param raw the raw stream to buffer
+ * @param fileSize size of the file being read, in bytes
+ * @return a buffered stream, or {@code raw} itself if buffering is disabled
or the file is empty
+ */
+ public static InputStream createInputStream(final InputStream raw, final
long fileSize) {
+ final long bufferSize = Math.min(fileSize, bufferSizeMax);
+ return bufferSize <= 0 ? raw : new BufferedInputStream(raw, (int)
bufferSize);
+ }
+
+ /**
+ * Borrow a reusable buffer of at least {@code minSize} bytes that does not
exceed the current
+ * cap. If the thread's pool holds a large enough buffer that still fits
within {@link
+ * #bufferSizeMax}, it is handed out (and removed from the pool, so
concurrent borrowers never see
+ * the same array); otherwise a new buffer is allocated.
+ */
+ static byte[] acquireBuffer(final int minSize) {
+ final long cap = bufferSizeMax;
+ final SoftReference<byte[]> reference = WRITE_BUFFER_POOL.get();
+ final byte[] cached = reference == null ? null : reference.get();
+ // Reuse only a buffer that fits the current cap: after the cap has been
lowered, a larger
+ // pooled buffer must not be handed out again, since the backing buffer
may never exceed
+ // bufferSizeMax.
+ if (cached != null && cached.length >= minSize && cached.length <= cap) {
+ // Take ownership of the cached buffer so a nested borrower allocates
its own instead of
+ // silently sharing the array.
+ WRITE_BUFFER_POOL.set(new SoftReference<>(null));
+ return cached;
+ }
+ return new byte[minSize];
+ }
+
+ /** Return a buffer to the thread's pool for reuse by a later snapshot on
the same thread. */
+ static void releaseBuffer(final byte[] buffer) {
+ if (buffer != null) {
+ WRITE_BUFFER_POOL.set(new SoftReference<>(buffer));
+ }
+ }
+}
diff --git
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactoryTest.java
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactoryTest.java
new file mode 100644
index 00000000000..e4e33e5e83f
--- /dev/null
+++
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/snapshot/SnapshotStreamFactoryTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.iotdb.commons.snapshot;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Random;
+
+public class SnapshotStreamFactoryTest {
+
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private long originalBufferSizeMax;
+
+ @Before
+ public void setUp() {
+ originalBufferSizeMax = SnapshotStreamFactory.getBufferSizeMax();
+ }
+
+ @After
+ public void tearDown() {
+ // The buffer size cap is process-global; restore it so tests do not leak
state into each
+ // other.
+ SnapshotStreamFactory.setBufferSizeMax(originalBufferSizeMax);
+ }
+
+ @Test
+ public void testDisabledBufferingReturnsRawStreams() {
+ SnapshotStreamFactory.setBufferSizeMax(0);
+
+ final OutputStream rawOut = new NullOutputStream();
+ final InputStream rawIn = new NullInputStream();
+ Assert.assertSame(rawOut,
SnapshotStreamFactory.createOutputStream(rawOut));
+ Assert.assertSame(rawIn, SnapshotStreamFactory.createInputStream(rawIn,
1024));
+ }
+
+ @Test
+ public void testBufferSizeMaximumBoundary() {
+ SnapshotStreamFactory.setBufferSizeMax(Integer.MAX_VALUE);
+ Assert.assertEquals(Integer.MAX_VALUE,
SnapshotStreamFactory.getBufferSizeMax());
+
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () -> SnapshotStreamFactory.setBufferSizeMax((long) Integer.MAX_VALUE
+ 1));
+ Assert.assertEquals(Integer.MAX_VALUE,
SnapshotStreamFactory.getBufferSizeMax());
+ }
+
+ @Test
+ public void testRoundTripThroughBufferedStreams() throws IOException {
+ // 100KB of random data with a 64KB buffer: writes must wrap the buffer
several times.
+ SnapshotStreamFactory.setBufferSizeMax(64 * 1024);
+ final byte[] data = new byte[100 * 1024];
+ new Random(42).nextBytes(data);
+
+ final File file = temporaryFolder.newFile();
+ try (FileOutputStream fileOutputStream = new FileOutputStream(file);
+ OutputStream outputStream =
SnapshotStreamFactory.createOutputStream(fileOutputStream)) {
+ // Write in 4KB chunks so the 64KB buffer is actually filled, flushed
and wrapped around.
+ for (int offset = 0; offset < data.length; offset += 4096) {
+ outputStream.write(data, offset, Math.min(4096, data.length - offset));
+ }
+ }
+
+ final byte[] readBack = new byte[data.length];
+ try (FileInputStream fileInputStream = new FileInputStream(file);
+ InputStream inputStream =
+ SnapshotStreamFactory.createInputStream(fileInputStream,
file.length())) {
+ int offset = 0;
+ while (offset < readBack.length) {
+ final int read = inputStream.read(readBack, offset, readBack.length -
offset);
+ if (read < 0) {
+ break;
+ }
+ offset += read;
+ }
+ Assert.assertEquals(readBack.length, offset);
+ }
+ Assert.assertArrayEquals(data, readBack);
+ }
+
+ @Test
+ public void testLargeWriteBypassesBuffer() throws IOException {
+ // A single write larger than the buffer must bypass it and still land
correctly on the
+ // underlying stream.
+ SnapshotStreamFactory.setBufferSizeMax(64 * 1024);
+ final byte[] data = new byte[1024 * 1024];
+ new Random(7).nextBytes(data);
+
+ final File file = temporaryFolder.newFile();
+ try (FileOutputStream fileOutputStream = new FileOutputStream(file);
+ OutputStream outputStream =
SnapshotStreamFactory.createOutputStream(fileOutputStream)) {
+ outputStream.write(data);
+ }
+ Assert.assertEquals(data.length, file.length());
+
+ final byte[] readBack = new byte[data.length];
+ try (InputStream inputStream = new FileInputStream(file)) {
+ int offset = 0;
+ while (offset < readBack.length) {
+ final int read = inputStream.read(readBack, offset, readBack.length -
offset);
+ if (read < 0) {
+ break;
+ }
+ offset += read;
+ }
+ Assert.assertEquals(readBack.length, offset);
+ }
+ Assert.assertTrue(Arrays.equals(data, readBack));
+ }
+
+ @Test
+ public void testWriteBufferReuse() {
+ // Use an explicit cap so the pool semantics do not depend on state left
by other tests.
+ SnapshotStreamFactory.setBufferSizeMax(128 * 1024);
+ final byte[] first = SnapshotStreamFactory.acquireBuffer(64 * 1024);
+ SnapshotStreamFactory.releaseBuffer(first);
+
+ // The released buffer is handed out again for a request that fits.
+ final byte[] second = SnapshotStreamFactory.acquireBuffer(64 * 1024);
+ Assert.assertSame(first, second);
+ SnapshotStreamFactory.releaseBuffer(second);
+
+ // A borrowed buffer is removed from the pool: a concurrent borrow must
not share it.
+ final byte[] borrowed = SnapshotStreamFactory.acquireBuffer(64 * 1024);
+ final byte[] other = SnapshotStreamFactory.acquireBuffer(64 * 1024);
+ Assert.assertNotSame(borrowed, other);
+ SnapshotStreamFactory.releaseBuffer(other);
+
+ // A smaller request reuses the larger cached buffer while it still fits
the cap.
+ final byte[] smaller = SnapshotStreamFactory.acquireBuffer(1024);
+ Assert.assertSame(other, smaller);
+ SnapshotStreamFactory.releaseBuffer(smaller);
+
+ // A request larger than the cached buffer allocates a fresh one.
+ final byte[] tiny = new byte[32 * 1024];
+ SnapshotStreamFactory.releaseBuffer(tiny);
+ final byte[] larger = SnapshotStreamFactory.acquireBuffer(64 * 1024);
+ Assert.assertNotSame(tiny, larger);
+ SnapshotStreamFactory.releaseBuffer(borrowed);
+ SnapshotStreamFactory.releaseBuffer(larger);
+ }
+
+ @Test
+ public void testBufferNotReusedAfterCapDecrease() {
+ // Prime the pool with a large write buffer under a large cap.
+ SnapshotStreamFactory.setBufferSizeMax(128 * 1024);
+ final byte[] large = SnapshotStreamFactory.acquireBuffer(128 * 1024);
+ SnapshotStreamFactory.releaseBuffer(large);
+
+ // Lower the cap: the pooled buffer now exceeds it and must not be handed
out again.
+ SnapshotStreamFactory.setBufferSizeMax(8192);
+ final byte[] borrowed = SnapshotStreamFactory.acquireBuffer(8192);
+ Assert.assertNotSame(large, borrowed);
+ Assert.assertTrue(borrowed.length <= 8192);
+ SnapshotStreamFactory.releaseBuffer(borrowed);
+
+ // The buffer allocated under the new cap is still reused for requests
that fit it.
+ final byte[] again = SnapshotStreamFactory.acquireBuffer(8192);
+ Assert.assertSame(borrowed, again);
+ SnapshotStreamFactory.releaseBuffer(again);
+ }
+
+ @Test
+ public void testCloseIsIdempotentAndWriteAfterCloseFailsOnFlush() throws
IOException {
+ final File file = temporaryFolder.newFile();
+ final OutputStream outputStream =
+ SnapshotStreamFactory.createOutputStream(new FileOutputStream(file));
+ outputStream.write(1);
+ outputStream.close();
+ // Double close must not throw.
+ outputStream.close();
+
+ // Writes and flushes after close must fail instead of silently losing
data.
+ Assert.assertThrows(IOException.class, () -> outputStream.write(2));
+ Assert.assertThrows(IOException.class, outputStream::flush);
+ // Closing an already closed stream remains idempotent.
+ outputStream.close();
+ }
+
+ @Test
+ public void testClosePropagatesFlushFailureAndClosesUnderlyingStream()
throws IOException {
+ SnapshotStreamFactory.setBufferSizeMax(64);
+ final FailingOutputStream rawOut = new FailingOutputStream(false);
+ final OutputStream outputStream =
SnapshotStreamFactory.createOutputStream(rawOut);
+ outputStream.write(1);
+
+ final IOException exception = Assert.assertThrows(IOException.class,
outputStream::close);
+ Assert.assertEquals("write failure", exception.getMessage());
+ Assert.assertTrue(rawOut.closed);
+ }
+
+ @Test
+ public void testCloseSuppressesUnderlyingCloseFailureAfterFlushFailure()
throws IOException {
+ SnapshotStreamFactory.setBufferSizeMax(64);
+ final FailingOutputStream rawOut = new FailingOutputStream(true);
+ final OutputStream outputStream =
SnapshotStreamFactory.createOutputStream(rawOut);
+ outputStream.write(1);
+
+ final IOException exception = Assert.assertThrows(IOException.class,
outputStream::close);
+ Assert.assertEquals("write failure", exception.getMessage());
+ Assert.assertEquals(1, exception.getSuppressed().length);
+ Assert.assertEquals("close failure",
exception.getSuppressed()[0].getMessage());
+ Assert.assertTrue(rawOut.closed);
+ }
+
+ @Test
+ public void testInputBufferNeverExceedsFileSizeOrCap() throws IOException {
+ // The read buffer of a stream created for a small file must be capped,
and reading must
+ // still see the whole content.
+ final byte[] data = new byte[100];
+ new Random(3).nextBytes(data);
+ final File file = temporaryFolder.newFile();
+ try (FileOutputStream fileOutputStream = new FileOutputStream(file)) {
+ fileOutputStream.write(data);
+ }
+
+ SnapshotStreamFactory.setBufferSizeMax(64);
+ final byte[] readBack = new byte[data.length];
+ try (FileInputStream fileInputStream = new FileInputStream(file);
+ InputStream inputStream =
+ SnapshotStreamFactory.createInputStream(fileInputStream,
file.length())) {
+ int offset = 0;
+ while (offset < readBack.length) {
+ final int read = inputStream.read(readBack, offset, readBack.length -
offset);
+ if (read < 0) {
+ break;
+ }
+ offset += read;
+ }
+ Assert.assertEquals(readBack.length, offset);
+ }
+ Assert.assertArrayEquals(data, readBack);
+ }
+
+ /** OutputStream that discards everything, used to test the
disabled-buffering fast path. */
+ private static final class NullOutputStream extends OutputStream {
+ @Override
+ public void write(final int b) {
+ // discard
+ }
+ }
+
+ /** InputStream that is always at EOF, used to test the disabled-buffering
fast path. */
+ private static final class NullInputStream extends InputStream {
+ @Override
+ public int read() {
+ return -1;
+ }
+ }
+
+ private static final class FailingOutputStream extends OutputStream {
+
+ private final boolean failOnClose;
+ private boolean closed;
+
+ private FailingOutputStream(final boolean failOnClose) {
+ this.failOnClose = failOnClose;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ throw new IOException("write failure");
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws
IOException {
+ throw new IOException("write failure");
+ }
+
+ @Override
+ public void close() throws IOException {
+ closed = true;
+ if (failOnClose) {
+ throw new IOException("close failure");
+ }
+ }
+ }
+}