From d8c59cd5b895bbd3e68992d20db5724f45a6cadc Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Fri, 16 Oct 2026 15:05:56 +0800 Subject: [PATCH 01/18] LOAD TsFile: use the Pair the resolver takes and cover the HA cases The router and the resolver each wrapped a (device, time partition) pair into a private record only to unwrap it again on the way in or out. Pair already carries the value equality the deduplication maps need, so keep the pair itself and drop both SlotKey records, the record and its conversion. Cover the two cluster cases the consensus LOAD path has to survive: - a LOAD that runs while one DataNode is down: restarting that DataNode must replay the LOAD commands it missed and serve the loaded file. - a LOAD whose region is migrated mid-flight: the LOAD has to follow the refreshed route or fall back to the tablet path, but every point of the file must be imported and the migration must finish. --- .../iotdb/db/it/IoTDBLoadTsFileClusterIT.java | 71 +++- .../it/IoTDBLoadTsFileRegionMigrationIT.java | 337 ++++++++++++++++++ .../scheduler/load/DataPartitionResolver.java | 10 +- .../scheduler/load/DataPartitionRouter.java | 33 +- 4 files changed, 419 insertions(+), 32 deletions(-) create mode 100644 integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java index a3d39c6fcd65..3603c3dd09ba 100644 --- a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java @@ -21,6 +21,7 @@ import org.apache.iotdb.consensus.ConsensusFactory; import org.apache.iotdb.it.env.EnvFactory; +import org.apache.iotdb.it.env.cluster.node.DataNodeWrapper; import org.apache.iotdb.it.framework.IoTDBTestRunner; import org.apache.iotdb.it.utils.TsFileGenerator; import org.apache.iotdb.itbase.category.ClusterIT; @@ -260,6 +261,74 @@ public void testLoadAfterOneDataNodeDown() throws Exception { } } + /** + * A LOAD that runs while one DataNode is down is replicated by consensus to the surviving + * replicas. When the stopped DataNode restarts, its replicas replay the LOAD commands they + * missed, so the restarted DataNode ends up serving the very same file. + */ + @Test + public void testRestartedDataNodeIsSyncedWithTheLoadedFile() throws Exception { + final long firstPointCount = generateTsFile(tmpDir, "sync-first.tsfile", 5_000, 1L); + + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + createDatabaseAndSchema(statement); + statement.execute("load \"" + tmpDir.getAbsolutePath() + "\""); + awaitPointCount(statement, firstPointCount); + } + + // LOAD the second file while DataNode 0 is down. With three replicas it holds a copy of every + // region, so it is the DataNode that has to catch up with the LOAD when it comes back. + final DataNodeWrapper restartedDataNode = EnvFactory.getEnv().getDataNodeWrapper(0); + restartedDataNode.stop(); + Assert.assertFalse(restartedDataNode.isAlive()); + + final long secondPointCount = generateTsFile(tmpDir, "sync-second.tsfile", 3_000, 100_000L); + final long expectedPointCount = firstPointCount + secondPointCount; + try (final Connection connection = + DriverManager.getConnection( + Config.IOTDB_URL_PREFIX + + EnvFactory.getEnv().getDataNodeWrapper(1).getIpAndPortString(), + "root", + "root"); + final Statement statement = connection.createStatement()) { + statement.execute("load \"" + tmpDir.getAbsolutePath() + "\""); + awaitPointCount(statement, expectedPointCount); + } + + restartedDataNode.start(); + Assert.assertTrue(restartedDataNode.isAlive()); + + // The connection is pinned to the restarted DataNode, so this waits for the replicas it + // replayed the LOAD into and fails if the restart left them behind. + awaitPointCount(restartedDataNode, expectedPointCount); + } + + /** Waits until the given statement sees the expected number of imported points. */ + private static void awaitPointCount(final Statement statement, final long expectedPointCount) { + Awaitility.await() + .atMost(2, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted(() -> Assert.assertEquals(expectedPointCount, queryPointCount(statement))); + } + + /** Waits until the points of every loaded file are queryable through the given DataNode. */ + private static void awaitPointCount( + final DataNodeWrapper dataNode, final long expectedPointCount) { + Awaitility.await() + .atMost(2, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted( + () -> { + try (final Connection connection = EnvFactory.getEnv().getConnection(dataNode); + final Statement statement = connection.createStatement()) { + Assert.assertEquals(expectedPointCount, queryPointCount(statement)); + } + }); + } + /** With two of three DataNodes down the LOAD must fail with a message. */ @Test public void testLoadWithTwoDataNodesDownFailsWithAMeaningfulMessage() throws Exception { @@ -365,7 +434,7 @@ private long generateTsFile( } } - private long queryPointCount(final Statement statement) throws Exception { + private static long queryPointCount(final Statement statement) throws Exception { long actualPointCount = 0; final List allDevices = new ArrayList<>(DEVICES); allDevices.addAll(ALIGNED_DEVICES); diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java new file mode 100644 index 000000000000..f45a42df17da --- /dev/null +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java @@ -0,0 +1,337 @@ +/* + * 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.db.it; + +import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant; +import org.apache.iotdb.consensus.ConsensusFactory; +import org.apache.iotdb.it.env.EnvFactory; +import org.apache.iotdb.it.framework.IoTDBTestRunner; +import org.apache.iotdb.it.utils.TsFileGenerator; +import org.apache.iotdb.itbase.category.ClusterIT; + +import org.apache.tsfile.enums.TSDataType; +import org.apache.tsfile.file.metadata.enums.TSEncoding; +import org.apache.tsfile.write.schema.IMeasurementSchema; +import org.apache.tsfile.write.schema.MeasurementSchema; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; + +import java.io.File; +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * LOAD TsFile has to keep working when the topology changes while a file is being imported: the + * route of a region can be re-resolved after a piece was rejected, and a LOAD that notices a + * migration falls back to inserting the file through the ordinary write path. Either way every + * point of the file has to end up in the cluster and the migration has to finish. + * + *

The data replication factor is 2 with three DataNodes, so a region always has exactly one + * DataNode that can be its migration destination. + */ +@RunWith(IoTDBTestRunner.class) +@Category({ClusterIT.class}) +public class IoTDBLoadTsFileRegionMigrationIT { + + private static final long PARTITION_INTERVAL = 100_000_000L; + private static final String DATABASE = "root.load_migrate"; + private static final List DEVICES = + Arrays.asList( + DATABASE + ".d1", DATABASE + ".d2", DATABASE + ".d3", DATABASE + ".d4", DATABASE + ".d5"); + private static final List MEASUREMENTS = Arrays.asList("s1", "s2", "s3", "s4"); + private static final int POINT_COUNT_PER_DEVICE = 20_000; + private static final int DATA_REPLICATION_FACTOR = 2; + private static final int SCHEMA_REPLICATION_FACTOR = 3; + private static final int CONFIG_NODE_NUM = 1; + private static final int DATA_NODE_NUM = 3; + + private File tmpDir; + + @BeforeClass + public static void setUpClass() { + EnvFactory.getEnv().getConfig().getCommonConfig().setTimePartitionInterval(PARTITION_INTERVAL); + EnvFactory.getEnv().getConfig().getCommonConfig().setEnforceStrongPassword(false); + EnvFactory.getEnv().getConfig().getCommonConfig().setPipeMemoryManagementEnabled(false); + EnvFactory.getEnv().getConfig().getCommonConfig().setDatanodeMemoryProportion("1:10:1:1:1:0"); + EnvFactory.getEnv().getConfig().getCommonConfig().setTargetChunkPointNum(1_000); + EnvFactory.getEnv().getConfig().getCommonConfig().setMaxNumberOfPointsInPage(500); + EnvFactory.getEnv() + .getConfig() + .getCommonConfig() + .setConfigNodeConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS) + .setSchemaRegionConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS) + .setDataRegionConsensusProtocolClass(ConsensusFactory.IOT_CONSENSUS) + .setSchemaReplicationFactor(SCHEMA_REPLICATION_FACTOR) + .setDataReplicationFactor(DATA_REPLICATION_FACTOR); + EnvFactory.getEnv().initClusterEnvironment(CONFIG_NODE_NUM, DATA_NODE_NUM); + } + + @AfterClass + public static void tearDownClass() { + EnvFactory.getEnv().cleanClusterEnvironment(); + } + + @Before + public void setUp() throws Exception { + tmpDir = new File(Files.createTempDirectory("load-migrate-it").toUri()); + } + + @After + public void tearDown() throws Exception { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + statement.execute("delete database " + DATABASE); + } catch (final Exception ignored) { + } + + final File[] files = tmpDir.listFiles(); + if (files != null) { + for (final File file : files) { + Assert.assertTrue(deleteRecursively(file)); + } + } + Assert.assertTrue(tmpDir.delete()); + } + + /** + * Starts a LOAD of a file large enough to still be dispatching pieces when a region of its + * database is migrated away from a DataNode it was routed to, then asserts that both the LOAD and + * the migration finish with every point of the file imported. + */ + @Test + public void testLoadWhileARegionMigrates() throws Exception { + final long writtenPointCount = generateTsFile(new File(tmpDir, "load-migrating.tsfile")); + + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + createDatabaseAndSchema(statement); + } + + final ExecutorService loader = Executors.newSingleThreadExecutor(); + try { + final Future load = + loader.submit( + () -> { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + statement.execute("load \"" + tmpDir.getAbsolutePath() + "\""); + return null; + } catch (final Exception e) { + return e; + } + }); + + // Wait until the LOAD allocated the data region of its points, then migrate that region while + // the LOAD is still splitting and dispatching the pieces of the file. + final int[] migration = awaitMigratableRegion(); + Assert.assertFalse("the LOAD finished before the migration could overlap it", load.isDone()); + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + statement.execute( + "migrate region " + migration[0] + " from " + migration[1] + " to " + migration[2]); + } + + // The LOAD may follow the migration with a refreshed route or fall back to the tablet path, + // but it must not report a failure to the client. + final Exception loadFailure = load.get(15, TimeUnit.MINUTES); + Assert.assertNull("the LOAD must survive a region migration: " + loadFailure, loadFailure); + + awaitRegionMigrated(migration[0], migration[1], migration[2]); + + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + awaitPointCount(statement, writtenPointCount); + } + } finally { + loader.shutdownNow(); + } + } + + /** + * Waits until the LOAD has allocated a data region of the database, and returns the region, one + * of the DataNodes that replicates it and a DataNode that does not, in that order. + */ + private static int[] awaitMigratableRegion() { + final AtomicReference migration = new AtomicReference<>(); + Awaitility.await() + .atMost(5, TimeUnit.MINUTES) + .pollInterval(200, TimeUnit.MILLISECONDS) + .ignoreExceptions() + .untilAsserted( + () -> { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + final Set allDataNodes = allDataNodeIds(statement); + final Map> regionMap = dataRegionMap(statement); + Assert.assertFalse("the LOAD must allocate a data region", regionMap.isEmpty()); + + for (final Map.Entry> entry : regionMap.entrySet()) { + final Set replicas = entry.getValue(); + final int destination = + allDataNodes.stream() + .filter(dataNodeId -> !replicas.contains(dataNodeId)) + .findAny() + .orElse(-1); + if (destination < 0) { + continue; + } + migration.set( + new int[] {entry.getKey(), replicas.iterator().next(), destination}); + return; + } + Assert.fail("no data region has a free migration destination yet"); + } + }); + return migration.get(); + } + + /** Waits until the migrated region left the source DataNode and reached the destination. */ + private static void awaitRegionMigrated( + final int regionId, final int sourceDataNode, final int destinationDataNode) { + Awaitility.await() + .atMost(5, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted( + () -> { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + final Set replicas = dataRegionMap(statement).get(regionId); + Assert.assertNotNull("the migrated region must still exist", replicas); + Assert.assertFalse( + "the migrated region must leave DataNode " + sourceDataNode, + replicas.contains(sourceDataNode)); + Assert.assertTrue( + "the migrated region must reach DataNode " + destinationDataNode, + replicas.contains(destinationDataNode)); + } + }); + } + + /** Waits until the given statement sees the expected number of imported points. */ + private static void awaitPointCount(final Statement statement, final long expectedPointCount) { + Awaitility.await() + .atMost(5, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted(() -> Assert.assertEquals(expectedPointCount, queryPointCount(statement))); + } + + private void createDatabaseAndSchema(final Statement statement) throws SQLException { + statement.execute("create database " + DATABASE); + for (final String device : DEVICES) { + for (final String measurement : MEASUREMENTS) { + statement.execute( + "create timeseries " + device + "." + measurement + " " + TSDataType.INT64.name()); + } + } + } + + /** The DataRegion rows of `show regions`, mapped to the DataNodes that replicate each region. */ + private static Map> dataRegionMap(final Statement statement) + throws SQLException { + final Map> regionMap = new HashMap<>(); + try (final ResultSet resultSet = statement.executeQuery("show regions")) { + while (resultSet.next()) { + if (!"DataRegion".equals(resultSet.getString(ColumnHeaderConstant.TYPE)) + || !DATABASE.equals(resultSet.getString(ColumnHeaderConstant.DATABASE))) { + continue; + } + final int regionId = resultSet.getInt(ColumnHeaderConstant.REGION_ID); + final int dataNodeId = resultSet.getInt(ColumnHeaderConstant.DATA_NODE_ID); + regionMap.computeIfAbsent(regionId, id -> new HashSet<>()).add(dataNodeId); + } + } + return regionMap; + } + + private static Set allDataNodeIds(final Statement statement) throws SQLException { + final Set dataNodeIds = new HashSet<>(); + try (final ResultSet resultSet = statement.executeQuery("show datanodes")) { + while (resultSet.next()) { + dataNodeIds.add(resultSet.getInt(ColumnHeaderConstant.NODE_ID)); + } + } + return dataNodeIds; + } + + private long generateTsFile(final File tsFile) throws Exception { + final List schemas = + Arrays.asList( + new MeasurementSchema(MEASUREMENTS.get(0), TSDataType.INT64, TSEncoding.PLAIN), + new MeasurementSchema(MEASUREMENTS.get(1), TSDataType.INT64, TSEncoding.PLAIN), + new MeasurementSchema(MEASUREMENTS.get(2), TSDataType.INT64, TSEncoding.PLAIN), + new MeasurementSchema(MEASUREMENTS.get(3), TSDataType.INT64, TSEncoding.PLAIN)); + try (final TsFileGenerator generator = new TsFileGenerator(tsFile)) { + for (final String device : DEVICES) { + generator.registerTimeseries(device, schemas); + generator.generateData(device, POINT_COUNT_PER_DEVICE, 1L, false, 1L); + } + return generator.getTotalNumber(); + } + } + + private static long queryPointCount(final Statement statement) throws Exception { + long actualPointCount = 0; + for (final String device : DEVICES) { + for (final String measurement : MEASUREMENTS) { + try (final ResultSet resultSet = + statement.executeQuery("select count(" + measurement + ") from " + device)) { + Assert.assertTrue(resultSet.next()); + actualPointCount += resultSet.getLong(1); + } + } + } + return actualPointCount; + } + + private static boolean deleteRecursively(final File file) { + final File[] children = file.listFiles(); + if (children != null) { + for (final File child : children) { + if (!deleteRecursively(child)) { + return false; + } + } + } + return file.delete(); + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionResolver.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionResolver.java index f20e74636ff2..3a9dbadf12f0 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionResolver.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionResolver.java @@ -43,7 +43,8 @@ class DataPartitionResolver { private final String userName; /** The replica set resolved for every slot so far. */ - private final Map resolvedPartitions = new HashMap<>(); + private final Map, TRegionReplicaSet> resolvedPartitions = + new HashMap<>(); DataPartitionResolver(final DataPartitionBatchFetcher partitionFetcher, final String userName) { this.partitionFetcher = @@ -63,7 +64,7 @@ List resolve(final List> final List indexesToFetch = new ArrayList<>(); for (int i = 0; i < slotList.size(); i++) { final Pair slot = slotList.get(i); - final TRegionReplicaSet resolved = resolvedPartitions.get(new SlotKey(slot.left, slot.right)); + final TRegionReplicaSet resolved = resolvedPartitions.get(slot); replicaSets.add(resolved); if (resolved == null) { slotsToFetch.add(slot); @@ -88,13 +89,10 @@ List resolve(final List> final TRegionReplicaSet replicaSet = fetched.get(i); replicaSets.set(indexesToFetch.get(i), replicaSet); final Pair slot = slotsToFetch.get(i); - resolvedPartitions.put(new SlotKey(slot.left, slot.right), replicaSet); + resolvedPartitions.put(slot, replicaSet); } } return replicaSets; } - - /** One (device, time partition) pair, the key the partitions are resolved by. */ - private record SlotKey(IDeviceID device, TTimePartitionSlot slot) {} } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionRouter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionRouter.java index 0d7478b307fa..f9caf454f0b7 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionRouter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/DataPartitionRouter.java @@ -67,8 +67,9 @@ List route(final List chunkDataList) { final int totalChunks = chunkDataList.size(); final int[] chunkPartitionIndexes = new int[totalChunks]; - final List distinctSlotList = new ArrayList<>(); - final Map slotToIndexMap = new HashMap<>(totalChunks); + final List> distinctSlotList = new ArrayList<>(); + final Map, Integer> slotToIndexMap = + new HashMap<>(totalChunks); // 1. Deduplicate (device, time-partition) pairs and record mapping index per chunk for (int i = 0; i < totalChunks; i++) { @@ -88,7 +89,7 @@ List route(final List chunkDataList) { chunkData.getTimePartitionSlot(), DataNodeQueryMessages.EXCEPTION_CHUNK_TIME_PARTITION_SLOT_CANNOT_BE_NULL_E5B04C6F); - final SlotKey slotKey = new SlotKey(device, slot); + final Pair slotKey = new Pair<>(device, slot); final Integer existingIndex = slotToIndexMap.get(slotKey); if (existingIndex == null) { @@ -102,8 +103,7 @@ List route(final List chunkDataList) { } // 2. Resolve the replica sets of the distinct slots, reusing the ones the judgement resolved. - final List replicaSets = - partitionResolver.resolve(toQuerySlots(distinctSlotList)); + final List replicaSets = partitionResolver.resolve(distinctSlotList); // 3. Map resolved replica sets back to original input order final List routedReplicaSets = new ArrayList<>(totalChunks); @@ -111,34 +111,17 @@ List route(final List chunkDataList) { final int partitionIndex = chunkPartitionIndexes[i]; final TRegionReplicaSet replicaSet = replicaSets.get(partitionIndex); if (replicaSet == null) { - final SlotKey slotKey = distinctSlotList.get(partitionIndex); + final Pair slotKey = distinctSlotList.get(partitionIndex); throw new IllegalStateException( String.format( DataNodeQueryMessages .EXCEPTION_NULL_REPLICA_SET_RESOLVED_FOR_DEVICE_ARG_AT_PARTITION_ARG_19CA94B2, - slotKey.device(), - slotKey.slot())); + slotKey.getLeft(), + slotKey.getRight())); } routedReplicaSets.add(replicaSet); } return routedReplicaSets; } - - // ------------------------------------------------------------------------- - // Internal Model - // ------------------------------------------------------------------------- - - /** The distinct slots in the pair form the resolver takes. */ - private static List> toQuerySlots( - final List distinctSlots) { - final List> slots = new ArrayList<>(distinctSlots.size()); - for (final SlotKey slot : distinctSlots) { - slots.add(new Pair<>(slot.device(), slot.slot())); - } - return slots; - } - - /** Composite key for slot deduplication; the only slot representation this class holds. */ - private record SlotKey(IDeviceID device, TTimePartitionSlot slot) {} } From 773880f21d5ca56f065dd940bb4b963857743286 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Mon, 19 Oct 2026 15:53:26 +0800 Subject: [PATCH 02/18] LOAD TsFile: verify a restarted DataNode's local replicas A connection pinned to the restarted DataNode can still read from another replica, so the point-count check alone can pass before that node has replayed the LOAD. Compare each local replica's on-disk size with the other replicas of its region instead, and disable compaction so the sizes stay comparable. --- .../iotdb/db/it/IoTDBLoadTsFileClusterIT.java | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java index 3603c3dd09ba..0cb700627ef2 100644 --- a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileClusterIT.java @@ -19,6 +19,7 @@ package org.apache.iotdb.db.it; +import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant; import org.apache.iotdb.consensus.ConsensusFactory; import org.apache.iotdb.it.env.EnvFactory; import org.apache.iotdb.it.env.cluster.node.DataNodeWrapper; @@ -50,6 +51,7 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -97,6 +99,14 @@ public static void setUpClass() { .setDataRegionConsensusProtocolClass(ConsensusFactory.IOT_CONSENSUS) .setSchemaReplicationFactor(REPLICATION_FACTOR) .setDataReplicationFactor(REPLICATION_FACTOR); + // The restart case compares the on-disk size of the replicas, so keep compaction from changing + // those sizes while the test waits for the restarted DataNode to catch up. + EnvFactory.getEnv() + .getConfig() + .getCommonConfig() + .setEnableSeqSpaceCompaction(false) + .setEnableUnseqSpaceCompaction(false) + .setEnableCrossSpaceCompaction(false); EnvFactory.getEnv().initClusterEnvironment(CONFIG_NODE_NUM, DATA_NODE_NUM); } @@ -299,11 +309,98 @@ public void testRestartedDataNodeIsSyncedWithTheLoadedFile() throws Exception { restartedDataNode.start(); Assert.assertTrue(restartedDataNode.isAlive()); - // The connection is pinned to the restarted DataNode, so this waits for the replicas it - // replayed the LOAD into and fails if the restart left them behind. + // A pinned connection may still read from another replica, so check the restarted node's own + // replicas before letting it answer the query. + awaitRestartedLocalReplicasHaveTheLoadedFile(restartedDataNode); awaitPointCount(restartedDataNode, expectedPointCount); } + /** + * Waits until every data region replica on the given DataNode has the same on-disk size as the + * other replicas of that region. A node that did not replay the LOAD keeps its pre-LOAD size. + */ + private static void awaitRestartedLocalReplicasHaveTheLoadedFile( + final DataNodeWrapper restartedDataNode) { + Awaitility.await() + .atMost(5, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted( + () -> { + final Map> regionSizes = dataRegionSizes(); + Assert.assertFalse( + "the loaded database must have data regions", regionSizes.isEmpty()); + + int restartedDataNodeId; + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + restartedDataNodeId = dataNodeId(statement, restartedDataNode); + } + + for (final Map.Entry> region : regionSizes.entrySet()) { + final Map replicaSizes = region.getValue(); + // A region without data on any replica has nothing to catch up with. + if (replicaSizes.values().stream().noneMatch(IoTDBLoadTsFileClusterIT::hasData)) { + continue; + } + final String restartedSize = replicaSizes.get(restartedDataNodeId); + Assert.assertTrue( + "region " + region.getKey() + " has no data on the restarted DataNode", + hasData(restartedSize)); + replicaSizes.forEach( + (dataNodeId, size) -> { + if (dataNodeId != restartedDataNodeId) { + Assert.assertEquals( + "region " + region.getKey() + " differs on DataNode " + dataNodeId, + size, + restartedSize); + } + }); + } + }); + } + + /** Whether a `show regions` size means the replica holds data. */ + private static boolean hasData(final String size) { + return size != null && !"Unknown".equals(size) && !"0 B".equals(size); + } + + /** The DataRegion rows of `show regions`, mapped to each replica's on-disk size. */ + private static Map> dataRegionSizes() throws Exception { + final Map> regionSizes = new HashMap<>(); + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("show regions")) { + while (resultSet.next()) { + if (!"DataRegion".equals(resultSet.getString(ColumnHeaderConstant.TYPE)) + || !DATABASE.equals(resultSet.getString(ColumnHeaderConstant.DATABASE))) { + continue; + } + regionSizes + .computeIfAbsent( + resultSet.getInt(ColumnHeaderConstant.REGION_ID), id -> new HashMap<>()) + .put( + resultSet.getInt(ColumnHeaderConstant.DATA_NODE_ID), + resultSet.getString(ColumnHeaderConstant.TSFILE_SIZE)); + } + } + return regionSizes; + } + + /** The cluster id of the given DataNode, matched by its client RPC endpoint. */ + private static int dataNodeId(final Statement statement, final DataNodeWrapper dataNode) + throws SQLException { + try (final ResultSet resultSet = statement.executeQuery("show datanodes")) { + while (resultSet.next()) { + if (dataNode.getIp().equals(resultSet.getString(ColumnHeaderConstant.RPC_ADDRESS)) + && dataNode.getPort() == resultSet.getInt(ColumnHeaderConstant.RPC_PORT)) { + return resultSet.getInt(ColumnHeaderConstant.NODE_ID); + } + } + } + throw new AssertionError("cannot find " + dataNode.getIpAndPortString() + " in show datanodes"); + } + /** Waits until the given statement sees the expected number of imported points. */ private static void awaitPointCount(final Statement statement, final long expectedPointCount) { Awaitility.await() From a3da4e06d37977302a5a2fded8df8e6751fd64f5 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Mon, 19 Oct 2026 16:06:09 +0800 Subject: [PATCH 03/18] LOAD TsFile: cover pieces dispatched after a region migrated A region may migrate while the LOAD is between two pieces. The piece dispatched after it still goes to the pinned route first, fails there, and must be retried on the route the region has now; when that route is gone, the piece must fail instead of being sent to a stale route. Both are covered with a scripted submitter, so no test hook is needed in production code. --- ...aseConsensusLoadStrategyMigrationTest.java | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TwoPhaseConsensusLoadStrategyMigrationTest.java diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TwoPhaseConsensusLoadStrategyMigrationTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TwoPhaseConsensusLoadStrategyMigrationTest.java new file mode 100644 index 000000000000..257e0fcf01d9 --- /dev/null +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TwoPhaseConsensusLoadStrategyMigrationTest.java @@ -0,0 +1,183 @@ +/* + * 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.db.queryengine.plan.scheduler.load; + +import org.apache.iotdb.common.rpc.thrift.TConsensusGroupId; +import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType; +import org.apache.iotdb.common.rpc.thrift.TDataNodeLocation; +import org.apache.iotdb.common.rpc.thrift.TEndPoint; +import org.apache.iotdb.common.rpc.thrift.TRegionReplicaSet; +import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId; +import org.apache.iotdb.db.queryengine.plan.planner.plan.node.load.LoadTsFileConsensusNode; +import org.apache.iotdb.db.queryengine.plan.planner.plan.node.load.LoadTsFilePieceNode; +import org.apache.iotdb.db.storageengine.load.memory.LoadTsFileDataCacheMemoryBlock; +import org.apache.iotdb.rpc.RpcUtils; +import org.apache.iotdb.rpc.TSStatusCode; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The LOAD keeps dispatching pieces while a region of the file migrates, so the pieces dispatched + * after the migration must follow the route the region has now instead of the one pinned before it. + */ +public class TwoPhaseConsensusLoadStrategyMigrationTest { + + private static final TConsensusGroupId REGION = + new TConsensusGroupId(TConsensusGroupType.DataRegion, 1); + + private static final TRegionReplicaSet ROUTE_BEFORE_MIGRATION = + new TRegionReplicaSet(REGION, Collections.singletonList(location(11))); + + private static final TRegionReplicaSet ROUTE_AFTER_MIGRATION = + new TRegionReplicaSet(REGION, Collections.singletonList(location(12))); + + @Test + public void testAPieceDispatchedAfterTheMigrationFollowsTheNewRoute() throws Exception { + final List submitted = new ArrayList<>(); + final TwoPhaseConsensusLoadStrategy strategy = + strategy( + submitter( + submitted, + (pieceIndex, route) -> pieceIndex == 1 && route.equals(ROUTE_BEFORE_MIGRATION), + ROUTE_AFTER_MIGRATION)); + + assertTrue(dispatch(strategy, piece(0), ROUTE_BEFORE_MIGRATION)); + // The region migrated while the LOAD was between two pieces: the second piece still goes to the + // pinned route first, fails there, and is retried on the route the region has now. + assertTrue(dispatch(strategy, piece(1), ROUTE_AFTER_MIGRATION)); + + assertEquals(Arrays.asList("0@11", "1@11", "1@12"), submitted); + assertEquals(ROUTE_AFTER_MIGRATION, routesOf(strategy).get(REGION)); + assertEquals(Long.valueOf(2L), pieceCounts(strategy).get(REGION)); + } + + @Test + public void testAPieceWhoseRouteIsGoneAfterTheMigrationIsNotDispatched() throws Exception { + final List submitted = new ArrayList<>(); + final TwoPhaseConsensusLoadStrategy strategy = + strategy( + submitter( + submitted, + (pieceIndex, route) -> pieceIndex == 1 && route.equals(ROUTE_BEFORE_MIGRATION), + null)); + + assertTrue(dispatch(strategy, piece(0), ROUTE_BEFORE_MIGRATION)); + // The route the piece was pinned to is gone and cannot be looked up again: the piece fails + // instead of being sent to a route the partition table no longer knows. + assertFalse(dispatch(strategy, piece(1), ROUTE_AFTER_MIGRATION)); + + assertEquals(Arrays.asList("0@11", "1@11"), submitted); + assertEquals(Long.valueOf(1L), pieceCounts(strategy).get(REGION)); + } + + private static TwoPhaseConsensusLoadStrategy strategy(final LoadConsensusSubmitter submitter) { + return new TwoPhaseConsensusLoadStrategy( + mock(LoadTsFileDispatcherImpl.class), + new DataPartitionResolver(mock(DataPartitionBatchFetcher.class), "root"), + mock(LoadTsFileDataCacheMemoryBlock.class), + submitter, + false); + } + + /** + * Submits on the given route, fails when the predicate says the route is stale, and resolves the + * route to the given one afterwards. + */ + private static LoadConsensusSubmitter submitter( + final List submitted, + final BiPredicate staleRoute, + final TRegionReplicaSet resolvedRoute) { + final LoadConsensusSubmitter submitter = mock(LoadConsensusSubmitter.class); + when(submitter.submit(any(), any())) + .thenAnswer( + invocation -> { + final TRegionReplicaSet route = invocation.getArgument(0); + final LoadTsFileConsensusNode piece = invocation.getArgument(1); + submitted.add( + piece.getPieceIndex() + + "@" + + route.getDataNodeLocations().get(0).getDataNodeId()); + return staleRoute.test(piece.getPieceIndex(), route) + ? RpcUtils.getStatus(TSStatusCode.DISPATCH_ERROR) + : RpcUtils.getStatus(TSStatusCode.SUCCESS_STATUS); + }); + when(submitter.resolveRoute(any(), any(Integer.class))).thenReturn(resolvedRoute); + return submitter; + } + + private static LoadTsFilePieceNode piece(final long pieceIndex) { + return new LoadTsFilePieceNode(new PlanNodeId("piece-" + pieceIndex), null, pieceIndex); + } + + private static boolean dispatch( + final TwoPhaseConsensusLoadStrategy strategy, + final LoadTsFilePieceNode piece, + final TRegionReplicaSet route) + throws Exception { + final Method method = + TwoPhaseConsensusLoadStrategy.class.getDeclaredMethod( + "dispatchOnePieceNode", LoadTsFilePieceNode.class, TRegionReplicaSet.class); + method.setAccessible(true); + return (boolean) method.invoke(strategy, piece, route); + } + + @SuppressWarnings("unchecked") + private static Map routesOf( + final TwoPhaseConsensusLoadStrategy strategy) throws Exception { + final Field field = TwoPhaseConsensusLoadStrategy.class.getDeclaredField("regionRoutes"); + field.setAccessible(true); + return (Map) field.get(strategy); + } + + @SuppressWarnings("unchecked") + private static Map pieceCounts( + final TwoPhaseConsensusLoadStrategy strategy) throws Exception { + final Field field = TwoPhaseConsensusLoadStrategy.class.getDeclaredField("regionPieceCounts"); + field.setAccessible(true); + return (Map) field.get(strategy); + } + + private static TDataNodeLocation location(final int dataNodeId) { + final TEndPoint endPoint = new TEndPoint("127.0.0." + dataNodeId, 9003); + return new TDataNodeLocation() + .setDataNodeId(dataNodeId) + .setClientRpcEndPoint(endPoint) + .setInternalEndPoint(endPoint) + .setMPPDataExchangeEndPoint(endPoint) + .setDataRegionConsensusEndPoint(endPoint) + .setSchemaRegionConsensusEndPoint(endPoint); + } +} From c545c804f9eba6c6cc739ca96598d911efd09575 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Mon, 19 Oct 2026 16:47:53 +0800 Subject: [PATCH 04/18] LOAD TsFile: buffer the precalculated chunk writer The writer used to hand every small write to the FileChannel, which costs one syscall per chunk header, per chunk and per metadata field. Collect the writes in memory and flush them once the buffer (1 MiB, allocated at half and grown on demand) is full, so a staged TsFile reaches the channel in large blocks. getPosition() counts the buffered bytes without flushing, so the chunk offsets recorded by the writer stay exact, and a seek flushes the buffer first. The size is configurable through the internal load_tsfile_write_buffer_size (startup and hot reload, must be positive); it is deliberately not part of iotdb-system.properties.template. --- .../iotdb/db/i18n/DataNodeMiscMessages.java | 6 + .../iotdb/db/i18n/DataNodeMiscMessages.java | 6 + .../org/apache/iotdb/db/conf/IoTDBConfig.java | 15 ++ .../apache/iotdb/db/conf/IoTDBDescriptor.java | 12 + .../write/BufferedFileChannelOutput.java | 246 ++++++++++++++++++ .../write/TsFilePrecalculatedChunkWriter.java | 92 +------ .../apache/iotdb/db/conf/PropertiesTest.java | 33 +++ .../write/BufferedFileChannelOutputTest.java | 161 ++++++++++++ 8 files changed, 483 insertions(+), 88 deletions(-) create mode 100644 iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java create mode 100644 iotdb-core/datanode/src/test/java/org/apache/tsfile/write/BufferedFileChannelOutputTest.java diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index 43cd5114c4b9..7268d28f3d50 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -1510,4 +1510,10 @@ private DataNodeMiscMessages() {} LOG_MPP_DATA_EXCHANGE_MAX_PAYLOAD_SIZE_ARG_IS_BELOW_MINIMUM_ALLOWED_VALUE_ARG_USING_ARG_794ABC76 = "mpp_data_exchange_max_payload_size_in_bytes (%d) is below the minimum allowed value %d, using %d"; + public static final String + MISC_EXCEPTION_LOAD_TSFILE_WRITE_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_AB1BA936 = + "loadTsFileWriteBufferSize should be greater than 0"; + public static final String EXCEPTION_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_BB68B5C4 = + "bufferSize should be greater than 0"; + } diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index 743d1aa19446..8aa4e8fa565b 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -1491,4 +1491,10 @@ private DataNodeMiscMessages() {} LOG_MPP_DATA_EXCHANGE_MAX_PAYLOAD_SIZE_ARG_IS_BELOW_MINIMUM_ALLOWED_VALUE_ARG_USING_ARG_794ABC76 = "mpp_data_exchange_max_payload_size_in_bytes(%d)低于允许的最小值 %d,将使用 %d"; + public static final String + MISC_EXCEPTION_LOAD_TSFILE_WRITE_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_AB1BA936 = + "loadTsFileWriteBufferSize 应大于 0"; + public static final String EXCEPTION_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_BB68B5C4 = + "bufferSize 应大于 0"; + } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java index f8006e8db8f4..919351473b52 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java @@ -1180,6 +1180,8 @@ public class IoTDBConfig implements DefaultEncodingProvider { private double loadWriteThroughputBytesPerSecond = -1; // Bytes/s + private int loadTsFileWriteBufferSize = 1024 * 1024; // 1 MiB + private long loadTabletConversionThresholdBytes = -1; private boolean loadActiveListeningEnable = true; @@ -4326,6 +4328,19 @@ public void setLoadWriteThroughputBytesPerSecond(double loadWriteThroughputBytes this.loadWriteThroughputBytesPerSecond = loadWriteThroughputBytesPerSecond; } + public int getLoadTsFileWriteBufferSize() { + return loadTsFileWriteBufferSize; + } + + public void setLoadTsFileWriteBufferSize(final int loadTsFileWriteBufferSize) { + if (loadTsFileWriteBufferSize <= 0) { + throw new IllegalArgumentException( + DataNodeMiscMessages + .MISC_EXCEPTION_LOAD_TSFILE_WRITE_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_AB1BA936); + } + this.loadTsFileWriteBufferSize = loadTsFileWriteBufferSize; + } + public long getLoadTabletConversionThresholdBytes() { return loadTabletConversionThresholdBytes; } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java index 71d67f0f1b88..21e9c6d973fc 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java @@ -2621,6 +2621,12 @@ private void loadLoadTsFileProps(TrimProperties properties) { "load_write_throughput_bytes_per_second", String.valueOf(conf.getLoadWriteThroughputBytesPerSecond())))); + conf.setLoadTsFileWriteBufferSize( + Integer.parseInt( + properties.getProperty( + "load_tsfile_write_buffer_size", + String.valueOf(conf.getLoadTsFileWriteBufferSize())))); + conf.setLoadTsFileAllowedDirs( Arrays.stream(properties.getProperty("load_tsfile_allowed_dirs", "").trim().split(",")) .filter(dir -> !dir.isEmpty()) @@ -2770,6 +2776,12 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE ConfigurationFileUtils.getConfigurationDefaultValue( "load_write_throughput_bytes_per_second")))); + conf.setLoadTsFileWriteBufferSize( + Integer.parseInt( + properties.getProperty( + "load_tsfile_write_buffer_size", + String.valueOf(conf.getLoadTsFileWriteBufferSize())))); + conf.setLoadTsFileAllowedDirs( Arrays.stream( properties diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java new file mode 100644 index 000000000000..98cd293ae688 --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java @@ -0,0 +1,246 @@ +/* + * 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.tsfile.write; + +import org.apache.iotdb.db.conf.IoTDBDescriptor; +import org.apache.iotdb.db.i18n.DataNodeMiscMessages; +import org.apache.iotdb.db.i18n.StorageEngineMessages; + +import org.apache.tsfile.write.writer.TsFileOutput; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.Arrays; +import java.util.Objects; + +/** + * A {@link TsFileOutput} that buffers writes and hands them to the {@link FileChannel} once the + * configured buffer size is reached. The buffer is allocated at half of that size and grows on + * demand, and it is flushed by {@link #force()}, {@link #flush()}, {@link #truncate(long)}, a seek + * to another position, or {@link #close()}. + */ +public final class BufferedFileChannelOutput extends OutputStream implements TsFileOutput { + + /** The buffer size used when the configured size is not positive. */ + private static final int DEFAULT_BUFFER_SIZE = 1024 * 1024; + + private final FileChannel channel; + private final int bufferSize; + private final DynamicBuffer buffer; + + /** Creates an output that buffers {@code load_tsfile_write_buffer_size} bytes. */ + public BufferedFileChannelOutput(final FileChannel channel) { + this(channel, configuredBufferSize()); + } + + /** + * Creates an output that buffers the given number of bytes. + * + * @param bufferSize the size of the buffer in bytes, must be positive + */ + public BufferedFileChannelOutput(final FileChannel channel, final int bufferSize) { + this.channel = + Objects.requireNonNull( + channel, StorageEngineMessages.EXCEPTION_CHANNEL_CANNOT_BE_NULL_0F79E0FB); + if (bufferSize <= 0) { + throw new IllegalArgumentException( + DataNodeMiscMessages.EXCEPTION_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_BB68B5C4); + } + this.bufferSize = bufferSize; + this.buffer = new DynamicBuffer(Math.max(1, bufferSize / 2)); + } + + private static int configuredBufferSize() { + final int configured = IoTDBDescriptor.getInstance().getConfig().getLoadTsFileWriteBufferSize(); + return configured > 0 ? configured : DEFAULT_BUFFER_SIZE; + } + + @Override + public synchronized void write(final int b) throws IOException { + write((byte) b); + } + + @Override + public synchronized void write(final byte b) throws IOException { + if (buffer.size() == bufferSize) { + flushBuffer(); + } + buffer.write(b); + } + + @Override + public synchronized void write(final byte[] bytes) throws IOException { + write(bytes, 0, bytes.length); + } + + @Override + public synchronized void write(final byte[] bytes, final int offset, final int length) + throws IOException { + Objects.checkFromIndexSize(offset, length, bytes.length); + if (length == 0) { + return; + } + if (length >= bufferSize) { + // A block at least as large as the buffer is not worth a copy. + flushBuffer(); + writeDirect(bytes, offset, length); + return; + } + if (length > bufferSize - buffer.size()) { + flushBuffer(); + } + buffer.write(bytes, offset, length); + } + + @Override + public synchronized void write(final ByteBuffer src) throws IOException { + final int length = src.remaining(); + if (length == 0) { + return; + } + if (length >= bufferSize) { + flushBuffer(); + while (src.hasRemaining()) { + channel.write(src); + } + return; + } + if (length > bufferSize - buffer.size()) { + flushBuffer(); + } + buffer.write(src); + } + + private void writeDirect(final byte[] bytes, final int offset, final int length) + throws IOException { + final ByteBuffer wrap = ByteBuffer.wrap(bytes, offset, length); + while (wrap.hasRemaining()) { + channel.write(wrap); + } + } + + /** Hands the buffered bytes to the channel, keeping the previous writes in order. */ + private void flushBuffer() throws IOException { + if (buffer.size() == 0) { + return; + } + final ByteBuffer wrap = ByteBuffer.wrap(buffer.array(), 0, buffer.size()); + while (wrap.hasRemaining()) { + channel.write(wrap); + } + buffer.clear(); + } + + @Override + public synchronized long getPosition() throws IOException { + // The buffered bytes are already logically written, so a position query must not flush them. + return channel.position() + buffer.size(); + } + + /** Moves the output to the given absolute position, flushing first when the position changes. */ + synchronized void position(final long offset) throws IOException { + if (offset == channel.position() + buffer.size()) { + return; + } + flushBuffer(); + channel.position(offset); + } + + @Override + public OutputStream wrapAsStream() { + return this; + } + + @Override + public synchronized void flush() throws IOException { + flushBuffer(); + } + + @Override + public synchronized void force() throws IOException { + flushBuffer(); + // Pass false to fsync file content without blocking on OS metadata modifications + channel.force(false); + } + + @Override + public synchronized void truncate(final long size) throws IOException { + flushBuffer(); + channel.truncate(size); + } + + @Override + public synchronized void close() throws IOException { + try { + flushBuffer(); + } finally { + channel.close(); + } + } + + /** A byte array that grows on demand and is only ever written to by its owner. */ + private static final class DynamicBuffer { + + private byte[] data; + private int size; + + private DynamicBuffer(final int initialCapacity) { + this.data = new byte[initialCapacity]; + } + + private void ensureCapacity(final int minCapacity) { + if (minCapacity > data.length) { + data = Arrays.copyOf(data, Math.max(data.length * 2, minCapacity)); + } + } + + private void write(final byte b) { + ensureCapacity(size + 1); + data[size++] = b; + } + + private void write(final byte[] src, final int offset, final int length) { + ensureCapacity(size + length); + System.arraycopy(src, offset, data, size, length); + size += length; + } + + private void write(final ByteBuffer src) { + final int length = src.remaining(); + ensureCapacity(size + length); + src.get(data, size, length); + size += length; + } + + private int size() { + return size; + } + + private byte[] array() { + return data; + } + + private void clear() { + size = 0; + } + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java index a6c0d996c981..409327dc126b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java @@ -88,7 +88,7 @@ public TsFilePrecalculatedChunkWriter(final File file) throws IOException { this.file = Objects.requireNonNull(file, StorageEngineMessages.EXCEPTION_FILE_CANNOT_BE_NULL_29A83D70); this.out = - new WritableFileChannelOutput( + new BufferedFileChannelOutput( FileChannel.open( file.toPath(), StandardOpenOption.CREATE, @@ -108,7 +108,7 @@ public TsFilePrecalculatedChunkWriter(final File file, final FileChannel channel throws IOException { this.file = file; this.out = - new WritableFileChannelOutput( + new BufferedFileChannelOutput( Objects.requireNonNull( channel, StorageEngineMessages.EXCEPTION_CHANNEL_CANNOT_BE_NULL_0F79E0FB)); this.sealed = false; @@ -206,7 +206,7 @@ public ChunkWriteResult writeChunk( */ private long alignToOffset(final long expectedOffset, final IDeviceID device, final Chunk chunk) throws IOException { - if (out instanceof WritableFileChannelOutput channelOutput) { + if (out instanceof BufferedFileChannelOutput channelOutput) { // 1. Expand file with zeros if jumping past current physical maximum bound if (expectedOffset > maxPhysicalFileSize) { final long paddingStartOffset = maxPhysicalFileSize; @@ -340,7 +340,7 @@ public void close() throws IOException { try { // Seek past the highest written data zone byte before building indices - if (out instanceof WritableFileChannelOutput channelOutput) { + if (out instanceof BufferedFileChannelOutput channelOutput) { channelOutput.position(maxPhysicalFileSize); } @@ -452,88 +452,4 @@ private String getFileForLog() { public record ChunkWriteResult( long actualChunkGroupHeaderOffset, long actualChunkOffset, long actualChunkEndOffset) {} - - // ------------------------------------------------------------------------- - // Channel Wrapper - // ------------------------------------------------------------------------- - - private static final class WritableFileChannelOutput implements TsFileOutput { - - private final FileChannel channel; - private final ByteBuffer singleByteBuffer = ByteBuffer.allocateDirect(1); - - private WritableFileChannelOutput(final FileChannel channel) { - this.channel = channel; - } - - @Override - public void write(final byte[] bytes) throws IOException { - write(bytes, 0, bytes.length); - } - - public void write(final byte[] bytes, final int offset, final int length) throws IOException { - write(ByteBuffer.wrap(bytes, offset, length)); - } - - @Override - public synchronized void write(final byte b) throws IOException { - singleByteBuffer.clear(); - singleByteBuffer.put(b); - singleByteBuffer.flip(); - write(singleByteBuffer); - } - - @Override - public void write(final ByteBuffer buffer) throws IOException { - while (buffer.hasRemaining()) { - channel.write(buffer); - } - } - - @Override - public long getPosition() throws IOException { - return channel.position(); - } - - private void position(final long offset) throws IOException { - channel.position(offset); - } - - @Override - public OutputStream wrapAsStream() { - return new OutputStream() { - @Override - public void write(final int b) throws IOException { - WritableFileChannelOutput.this.write((byte) b); - } - - @Override - public void write(final byte[] bytes, final int offset, final int length) - throws IOException { - WritableFileChannelOutput.this.write(bytes, offset, length); - } - }; - } - - @Override - public void flush() throws IOException { - force(); - } - - @Override - public void force() throws IOException { - // Pass false to fsync file content without blocking on OS metadata modifications - channel.force(false); - } - - @Override - public void truncate(final long size) throws IOException { - channel.truncate(size); - } - - @Override - public void close() throws IOException { - channel.close(); - } - } } diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java index b0d7aef7394d..f7a1eca4f7cb 100755 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java @@ -19,6 +19,7 @@ package org.apache.iotdb.db.conf; +import org.apache.iotdb.calc.exception.QueryProcessException; import org.apache.iotdb.commons.conf.CommonConfig; import org.apache.iotdb.commons.conf.CommonDescriptor; import org.apache.iotdb.commons.conf.ConfigurationFileUtils; @@ -69,6 +70,38 @@ public void testWalFileListCacheConfiguration() throws Exception { } } + /** The LOAD write buffer defaults to 1 MiB, can be overridden, and must stay positive. */ + @Test + public void testLoadTsFileWriteBufferSizeDefaultsAndReloads() throws Exception { + final IoTDBDescriptor descriptor = IoTDBDescriptor.getInstance(); + final int originalValue = descriptor.getConfig().getLoadTsFileWriteBufferSize(); + + try { + Assert.assertEquals(1024 * 1024, new IoTDBConfig().getLoadTsFileWriteBufferSize()); + + final TrimProperties properties = new TrimProperties(); + properties.setProperty("load_tsfile_write_buffer_size", "4096"); + descriptor.loadProperties(properties); + Assert.assertEquals(4096, descriptor.getConfig().getLoadTsFileWriteBufferSize()); + + properties.setProperty("load_tsfile_write_buffer_size", "8192"); + descriptor.loadHotModifiedProps(properties); + Assert.assertEquals(8192, descriptor.getConfig().getLoadTsFileWriteBufferSize()); + + properties.setProperty("load_tsfile_write_buffer_size", "0"); + Assert.assertThrows( + QueryProcessException.class, () -> descriptor.loadHotModifiedProps(properties)); + Assert.assertEquals( + "an invalid value keeps the current one", + 8192, + descriptor.getConfig().getLoadTsFileWriteBufferSize()); + } finally { + final TrimProperties properties = new TrimProperties(); + properties.setProperty("load_tsfile_write_buffer_size", Integer.toString(originalValue)); + descriptor.loadHotModifiedProps(properties); + } + } + @Test public void testDeviceEntryBatchSizeIsCappedByThriftFrameSizeOnStartup() throws Exception { final IoTDBDescriptor descriptor = IoTDBDescriptor.getInstance(); diff --git a/iotdb-core/datanode/src/test/java/org/apache/tsfile/write/BufferedFileChannelOutputTest.java b/iotdb-core/datanode/src/test/java/org/apache/tsfile/write/BufferedFileChannelOutputTest.java new file mode 100644 index 000000000000..fc3f85ab1a60 --- /dev/null +++ b/iotdb-core/datanode/src/test/java/org/apache/tsfile/write/BufferedFileChannelOutputTest.java @@ -0,0 +1,161 @@ +/* + * 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.tsfile.write; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +/** Writes are buffered until the buffer is full, and the position counts the buffered bytes. */ +public class BufferedFileChannelOutputTest { + + @Rule public final TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void testSmallWritesStayInTheBufferUntilFlushed() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 64)) { + out.write("hello".getBytes(StandardCharsets.UTF_8)); + + assertEquals(5L, out.getPosition()); + assertEquals("nothing is on disk yet", 0L, channel.size()); + + out.flush(); + assertEquals(5L, channel.size()); + } + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file.toPath())); + } + + @Test + public void testWritesAreFlushedWhenTheBufferWouldOverflow() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 8)) { + out.write(new byte[] {1, 2, 3, 4, 5}); + out.write(new byte[] {6, 7, 8, 9, 10}); + + assertEquals("the first block had to leave the buffer", 5L, channel.size()); + assertEquals(10L, out.getPosition()); + + out.force(); + assertEquals(10L, channel.size()); + } + } + + @Test + public void testAWriteAtLeastAsLargeAsTheBufferBypassesIt() throws Exception { + final File file = folder.newFile(); + final byte[] block = new byte[100]; + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 8)) { + out.write(block); + + assertEquals("a large block is written directly", 100L, channel.size()); + assertEquals(100L, out.getPosition()); + } + assertArrayEquals(block, Files.readAllBytes(file.toPath())); + } + + @Test + public void testByteBufferWritesAreBuffered() throws Exception { + final File file = folder.newFile(); + final byte[] block = {1, 2, 3}; + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 8)) { + final ByteBuffer src = ByteBuffer.wrap(block); + out.write(src); + + assertEquals(0, src.remaining()); + assertEquals(3L, out.getPosition()); + assertEquals(0L, channel.size()); + + out.force(); + assertEquals(3L, channel.size()); + } + assertArrayEquals(block, Files.readAllBytes(file.toPath())); + } + + @Test + public void testSeekingToAnotherPositionFlushesFirst() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 64)) { + out.write("abc".getBytes(StandardCharsets.UTF_8)); + out.position(1L); + out.write("Z".getBytes(StandardCharsets.UTF_8)); + out.force(); + } + assertEquals("aZc", new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + } + + @Test + public void testSeekingToTheCurrentPositionDoesNotFlush() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 64)) { + out.write("abc".getBytes(StandardCharsets.UTF_8)); + out.position(out.getPosition()); + + assertEquals(0L, channel.size()); + } + } + + @Test + public void testTheOutputIsItsOwnStream() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file); + final BufferedFileChannelOutput out = new BufferedFileChannelOutput(channel, 64)) { + assertSame(out, out.wrapAsStream()); + } + } + + @Test + public void testANonPositiveBufferSizeIsRejected() throws Exception { + final File file = folder.newFile(); + try (final FileChannel channel = open(file)) { + assertThrows(IllegalArgumentException.class, () -> new BufferedFileChannelOutput(channel, 0)); + assertThrows( + IllegalArgumentException.class, () -> new BufferedFileChannelOutput(channel, -1)); + } + } + + private static FileChannel open(final File file) throws IOException { + return FileChannel.open( + file.toPath(), + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.READ, + StandardOpenOption.TRUNCATE_EXISTING); + } +} From 5bffe3d65958f4652df4279cb26afcf8c7f8c4ea Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 10:23:50 +0800 Subject: [PATCH 05/18] Decode an aligned value page once when splitting a TsFile Resolve the bounds of the time partition of an AlignedChunkData once instead of once per buffered point. TimePartitionUtils derives the bounds through BigInteger arithmetic, so a chunk group whose points sit one time partition apart otherwise runs O(points * partitions) bound computations and allocates the matching temporary objects while the pages are encoded. Splitting a TsFile of 2048 points, one per time partition, drops from 1.5 GB to under 128 MB of allocation. The splitter already decodes a value page once and shares the array with every partition that it spans; the new TsFileSplitterTest case covers the whole split and reports the 1.5 GB without this change. Verified with TsFileSplitterTest of iotdb-core/datanode: 5 tests, 0 failures. --- .../load/splitter/AlignedChunkData.java | 23 +++-- .../load/TsFileSplitterTest.java | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index 370e6854c5f4..e8fb7519ff4f 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -398,6 +398,13 @@ private AlignedChunkWriterImpl createAlignedChunkWriter() { private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) throws IOException, PageException { + // The partition bounds do not depend on the point and TimePartitionUtils derives them with + // BigInteger arithmetic, so resolve them once per ChunkData instead of once per buffered point: + // a page that covers thousands of time partitions otherwise costs O(points * partitions) bound + // computations and the matching allocation churn. The end time is the overflow-safe one, so the + // last representable partition still keeps its upper bound. + final long partitionStart = timePartitionSlot.getStartTime(); + final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); for (final PageBuffer page : pageBuffers) { if (!page.needDecode) { // Skip empty pages: an aligned series stores an all-null column as an empty page, and @@ -416,7 +423,7 @@ private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) if (page.valueBatch == null) { boolean hasPoint = false; for (final long time : page.timeBatch) { - if (!isInCurrentTimePartition(time)) { + if (time < partitionStart || time > partitionEnd) { continue; } writer.writeTime(time); @@ -429,11 +436,12 @@ private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) final TSDataType dataType = chunkHeaderList.get(page.chunkIndex).getDataType(); boolean hasPoint = false; for (int i = 0; i < page.timeBatch.length; i++) { - if (!isInCurrentTimePartition(page.timeBatch[i])) { + final long time = page.timeBatch[i]; + if (time < partitionStart || time > partitionEnd) { continue; } writeValueToAlignedChunkWriter( - writer, page.timeBatch[i], page.valueBatch[i], dataType, page.chunkIndex - 1); + writer, time, page.valueBatch[i], dataType, page.chunkIndex - 1); hasPoint = true; } if (hasPoint) { @@ -443,15 +451,6 @@ private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) } } - protected boolean isInCurrentTimePartition(final long time) { - final long partitionStart = timePartitionSlot.getStartTime(); - // Use the overflow-safe end time. A naive "start + interval - 1" wraps around for the last - // representable partition (and equals the start when interval == 1), which would either drop - // the upper bound or exclude points that belong to the current partition. - final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); - return time >= partitionStart && time <= partitionEnd; - } - private void writeValueToAlignedChunkWriter( final AlignedChunkWriterImpl writer, final long time, diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java index 1797c4011602..8fbcfba589fa 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java @@ -22,8 +22,12 @@ import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot; import org.apache.iotdb.commons.conf.CommonDescriptor; import org.apache.iotdb.commons.utils.TimePartitionUtils; +import org.apache.iotdb.db.conf.IoTDBConfig; +import org.apache.iotdb.db.conf.IoTDBDescriptor; +import com.sun.management.ThreadMXBean; import org.apache.tsfile.common.conf.TSFileConfig; +import org.apache.tsfile.common.conf.TSFileDescriptor; import org.apache.tsfile.enums.ColumnCategory; import org.apache.tsfile.enums.TSDataType; import org.apache.tsfile.file.MetaMarker; @@ -48,6 +52,7 @@ import org.apache.tsfile.write.writer.TsFileIOWriter; import org.apache.tsfile.write.writer.tsmiterator.TSMIterator; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import java.io.ByteArrayInputStream; @@ -55,12 +60,14 @@ import java.io.DataOutputStream; import java.io.File; import java.io.IOException; +import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; public class TsFileSplitterTest { @@ -93,6 +100,98 @@ public void testSplitV3TsFile() throws Exception { } } + @Test + public void testSplitAlignedChunkAcrossManyTimePartitionsDecodesEachPageOnce() throws Exception { + // One point per time partition in a single chunk group makes every value page span as many + // time partitions as the page holds points. Such a page must be decoded once and shared by the + // partitions that it covers: decoding it once per partition allocates one page-sized array per + // partition, i.e. O(points * partitions) objects, which exhausts the DataNode heap when a + // TsFile stores its points one time partition apart. + final int pointsPerPage = 1024; + final int pointCount = 2 * pointsPerPage; + final File sourceTsFile = new File("split-aligned-many-partitions-source.tsfile"); + final IoTDBConfig config = IoTDBDescriptor.getInstance().getConfig(); + final int originalPartitionSlotsLimit = config.getLoadTsFileSpiltPartitionMaxSize(); + final int originalMaxPointsInPage = + TSFileDescriptor.getInstance().getConfig().getMaxNumberOfPointsInPage(); + try { + config.setLoadTsFileSpiltPartitionMaxSize(pointCount + 1); + TSFileDescriptor.getInstance().getConfig().setMaxNumberOfPointsInPage(pointsPerPage); + writeTableTsFileWithOnePointPerTimePartition(sourceTsFile, pointCount); + + final AtomicInteger emittedChunkDataCount = new AtomicInteger(); + final long allocatedBytes = + splitAndMeasureAllocatedBytes(sourceTsFile, emittedChunkDataCount); + + Assert.assertEquals(pointCount, emittedChunkDataCount.get()); + // Decoding the pages per partition too and recomputing the partition bounds per point both + // make this O(points * partitions) in allocations, which is hundreds of megabytes here. + Assert.assertTrue( + "splitting " + + pointCount + + " single-point time partitions allocated " + + allocatedBytes + + " bytes", + allocatedBytes < 128L * 1024 * 1024); + } finally { + config.setLoadTsFileSpiltPartitionMaxSize(originalPartitionSlotsLimit); + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(originalMaxPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + } + } + + private long splitAndMeasureAllocatedBytes( + final File sourceTsFile, final AtomicInteger emittedChunkDataCount) throws Exception { + final java.lang.management.ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + Assume.assumeTrue( + "the JVM does not report the allocations of a thread", + threadMXBean instanceof ThreadMXBean); + final long allocatedBytesBefore = + ((ThreadMXBean) threadMXBean).getCurrentThreadAllocatedBytes(); + new TsFileSplitter( + sourceTsFile, + tsFileData -> { + if (tsFileData instanceof ChunkData) { + emittedChunkDataCount.incrementAndGet(); + } + return true; + }) + .splitTsFileByDataPartition(); + return ((ThreadMXBean) threadMXBean).getCurrentThreadAllocatedBytes() - allocatedBytesBefore; + } + + private void writeTableTsFileWithOnePointPerTimePartition(final File tsFile, final int pointCount) + throws Exception { + if (tsFile.exists()) { + Assert.assertTrue(tsFile.delete()); + } + final long baseTime = + TimePartitionUtils.getTimePartitionSlot(1_700_000_000_000L).getStartTime(); + final long timePartitionInterval = TimePartitionUtils.getTimePartitionInterval(); + + try (final TsFileIOWriter writer = new TsFileIOWriter(tsFile)) { + writer.setSchema(createSchema()); + writer.startChunkGroup(new StringArrayDeviceID("table1", "tagA")); + + final AlignedChunkWriterImpl chunkWriter = + new AlignedChunkWriterImpl( + Collections.singletonList(new MeasurementSchema("s1", TSDataType.INT64))); + for (int i = 0; i < pointCount; i++) { + final long time = baseTime + i * timePartitionInterval; + chunkWriter.getTimeChunkWriter().write(time); + chunkWriter.getValueChunkWriterByIndex(0).write(time, (long) i, false); + } + chunkWriter.writeToFileWriter(writer); + + writer.endChunkGroup(); + writer.endFile(); + } + } + private File constructV3TsFile() throws IOException { final File tsFile = Files.createTempFile("v3-tsfile-splitter", ".tsfile").toFile(); final IDeviceID deviceID = new PlainDeviceID("root.sg.d1"); From dd98aa4c44ee178f586e1e4ff53cf7f304059d2c Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 11:38:43 +0800 Subject: [PATCH 06/18] Copy undecoded aligned pages directly when splitting a TsFile --- .../load/splitter/AlignedChunkData.java | 182 +++++++++++++++--- .../BatchedAlignedValueChunkData.java | 9 + .../load/TsFileSplitterTest.java | 118 ++++++++++++ 3 files changed, 287 insertions(+), 22 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index e8fb7519ff4f..0b8609cf400b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -28,6 +28,7 @@ import org.apache.tsfile.encrypt.EncryptUtils; import org.apache.tsfile.enums.TSDataType; import org.apache.tsfile.exception.write.PageException; +import org.apache.tsfile.file.MetaMarker; import org.apache.tsfile.file.header.ChunkHeader; import org.apache.tsfile.file.header.PageHeader; import org.apache.tsfile.file.metadata.IChunkMetadata; @@ -50,11 +51,14 @@ import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.Set; @@ -325,10 +329,6 @@ public void endChunk() { } protected void encodeAndBuildChunks() throws IOException, PageException { - final AlignedChunkWriterImpl writer = createAlignedChunkWriter(); - - writePageBuffersToWriter(writer); - // One aligned ChunkData may accumulate pages from several physical Chunks before it is // consumed. Encode every Chunk represented by the current buffers, while leaving Chunks that // were sealed by an earlier endChunk() untouched. @@ -336,7 +336,40 @@ protected void encodeAndBuildChunks() throws IOException, PageException { for (final PageBuffer pageBuffer : pageBuffers) { bufferedChunkIndexes.add(pageBuffer.chunkIndex); } + + // A chunk whose pages were never split only needs its page headers and data copied. Building it + // through AlignedChunkWriterImpl would encode every page again and then copy the whole chunk + // out + // of the writer's growable buffer, so its peak cost is roughly four times the chunk size. + final Map directlyBuiltChunks = new LinkedHashMap<>(); + for (final int chunkIndex : bufferedChunkIndexes) { + final Chunk chunk = + buildChunkFromUndecodedPages( + chunkIndex, + chunkHeaderList.get(chunkIndex), + chunkIndex == 0 ? TIME_COLUMN_MASK : VALUE_COLUMN_MASK); + if (chunk != null) { + directlyBuiltChunks.put(chunkIndex, chunk); + } + } + + final AlignedChunkWriterImpl writer = + directlyBuiltChunks.size() == bufferedChunkIndexes.size() + ? null + : createAlignedChunkWriter(); + if (writer != null) { + writePageBuffersToWriter(writer, directlyBuiltChunks.keySet()); + } + for (final int chunkIndex : bufferedChunkIndexes) { + final Chunk directChunk = directlyBuiltChunks.get(chunkIndex); + if (directChunk != null) { + entireChunks.add(directChunk); + dataSize += + directChunk.getData().remaining() + directChunk.getChunkStatistic().getSerializedSize(); + continue; + } + final ChunkHeader sourceHeader = chunkHeaderList.get(chunkIndex); final ByteBuffer encodedData; final Statistics statistics; @@ -362,6 +395,84 @@ protected void encodeAndBuildChunks() throws IOException, PageException { } } + /** + * Reassembles a chunk from pages that were stored without decoding. Returns {@code null} when the + * chunk has a decoded page, an empty page whose statistics cannot be merged, or no page at all; + * the caller then falls back to {@link AlignedChunkWriterImpl}. + */ + protected Chunk buildChunkFromUndecodedPages( + final int chunkIndex, final ChunkHeader sourceHeader, final int mask) throws IOException { + // A time chunk header carries VECTOR, and its pages use TimeStatistics rather than the + // LongStatistics that Statistics.getStatsByType(INT64) would create. + final TSDataType statisticsType = sourceHeader.getDataType(); + Statistics statistics = null; + int totalSize = 0; + int pageCount = 0; + for (final PageBuffer page : pageBuffers) { + if (page.chunkIndex != chunkIndex) { + continue; + } + if (page.needDecode || page.pageHeader == null || page.pageHeader.getStatistics() == null) { + return null; + } + if (page.pageData.remaining() == 0) { + continue; + } + if (statistics == null) { + statistics = Statistics.getStatsByType(statisticsType); + } + statistics.mergeStatistics(page.pageHeader.getStatistics()); + totalSize += page.pageHeader.getSerializedPageSize(); + pageCount++; + } + if (statistics == null) { + return null; + } + + final ByteBuffer encodedData = ByteBuffer.allocate(totalSize); + final OutputStream headerStream = new ByteBufferOutputStream(encodedData); + for (final PageBuffer page : pageBuffers) { + if (page.chunkIndex != chunkIndex || page.pageData.remaining() == 0) { + continue; + } + page.pageHeader.serializeTo(headerStream); + encodedData.put(page.pageData.duplicate()); + } + encodedData.flip(); + // A chunk with one page is normally written with ONLY_ONE_PAGE_CHUNK_HEADER, whose page + // header does not carry statistics. The pages copied here keep their statistics, so use the + // multi-page marker to make them readable. + final ChunkHeader encodedHeader = + pageCount == 1 + ? new ChunkHeader( + (byte) (MetaMarker.CHUNK_HEADER | mask), + sourceHeader.getMeasurementID(), + totalSize, + sourceHeader.getDataType(), + sourceHeader.getCompressionType(), + sourceHeader.getEncodingType()) + : createEncodedChunkHeader(sourceHeader, totalSize, pageCount, mask); + return new Chunk(encodedHeader, encodedData, null, statistics); + } + + private static final class ByteBufferOutputStream extends OutputStream { + private final ByteBuffer buffer; + + private ByteBufferOutputStream(final ByteBuffer buffer) { + this.buffer = buffer; + } + + @Override + public void write(final int b) { + buffer.put((byte) b); + } + + @Override + public void write(final byte[] bytes, final int offset, final int length) { + buffer.put(bytes, offset, length); + } + } + protected static ChunkHeader createEncodedChunkHeader( final ChunkHeader sourceHeader, final int encodedDataSize, @@ -396,7 +507,8 @@ private AlignedChunkWriterImpl createAlignedChunkWriter() { return new AlignedChunkWriterImpl(timeSchema, valueSchemaList, encryptParameter); } - private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) + private void writePageBuffersToWriter( + final AlignedChunkWriterImpl writer, final Set directlyBuiltChunkIndexes) throws IOException, PageException { // The partition bounds do not depend on the point and TimePartitionUtils derives them with // BigInteger arithmetic, so resolve them once per ChunkData instead of once per buffered point: @@ -406,6 +518,9 @@ private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) final long partitionStart = timePartitionSlot.getStartTime(); final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); for (final PageBuffer page : pageBuffers) { + if (directlyBuiltChunkIndexes.contains(page.chunkIndex)) { + continue; + } if (!page.needDecode) { // Skip empty pages: an aligned series stores an all-null column as an empty page, and // writing its page header alone yields a chunk that cannot be parsed back. @@ -420,37 +535,60 @@ private void writePageBuffersToWriter(final AlignedChunkWriterImpl writer) } continue; } + // Times inside a page are sorted, so the points of this partition are one contiguous range. + // Locating that range with two binary searches avoids rescanning the whole page for every + // partition: a page that covers thousands of partitions used to cost O(points * partitions) + // bound comparisons even after the bounds themselves were cached. + final int from = lowerBound(page.timeBatch, partitionStart); + final int to = upperBound(page.timeBatch, partitionEnd); if (page.valueBatch == null) { - boolean hasPoint = false; - for (final long time : page.timeBatch) { - if (time < partitionStart || time > partitionEnd) { - continue; - } - writer.writeTime(time); - hasPoint = true; + for (int i = from; i < to; i++) { + writer.writeTime(page.timeBatch[i]); } - if (hasPoint) { + if (from < to) { writer.sealCurrentTimePage(); } } else { final TSDataType dataType = chunkHeaderList.get(page.chunkIndex).getDataType(); - boolean hasPoint = false; - for (int i = 0; i < page.timeBatch.length; i++) { - final long time = page.timeBatch[i]; - if (time < partitionStart || time > partitionEnd) { - continue; - } + for (int i = from; i < to; i++) { writeValueToAlignedChunkWriter( - writer, time, page.valueBatch[i], dataType, page.chunkIndex - 1); - hasPoint = true; + writer, page.timeBatch[i], page.valueBatch[i], dataType, page.chunkIndex - 1); } - if (hasPoint) { + if (from < to) { writer.sealCurrentValuePage(page.chunkIndex - 1); } } } } + private static int lowerBound(final long[] times, final long key) { + int low = 0; + int high = times.length; + while (low < high) { + final int mid = (low + high) >>> 1; + if (times[mid] < key) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } + + private static int upperBound(final long[] times, final long key) { + int low = 0; + int high = times.length; + while (low < high) { + final int mid = (low + high) >>> 1; + if (times[mid] <= key) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } + private void writeValueToAlignedChunkWriter( final AlignedChunkWriterImpl writer, final long time, diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java index 9db2c9590b2b..8aba3582d6cc 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java @@ -72,6 +72,15 @@ protected void encodeAndBuildChunks() throws IOException, PageException { // Process each standalone value chunk for (int i = 0; i < chunkHeaderList.size(); i++) { final ChunkHeader chunkHeader = chunkHeaderList.get(i); + final Chunk directlyBuiltChunk = + buildChunkFromUndecodedPages(i, chunkHeader, VALUE_COLUMN_MASK); + if (directlyBuiltChunk != null) { + entireChunks.add(directlyBuiltChunk); + dataSize += + directlyBuiltChunk.getData().remaining() + + directlyBuiltChunk.getChunkStatistic().getSerializedSize(); + continue; + } MeasurementSchema measurementSchema = new MeasurementSchema( chunkHeader.getMeasurementID(), diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java index 8fbcfba589fa..897b8f2074fd 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java @@ -42,7 +42,13 @@ import org.apache.tsfile.file.metadata.TableSchema; import org.apache.tsfile.file.metadata.TimeseriesMetadata; import org.apache.tsfile.file.metadata.enums.MetadataIndexNodeType; +import org.apache.tsfile.read.TsFileReader; import org.apache.tsfile.read.TsFileSequenceReader; +import org.apache.tsfile.read.common.Field; +import org.apache.tsfile.read.common.Path; +import org.apache.tsfile.read.common.RowRecord; +import org.apache.tsfile.read.expression.QueryExpression; +import org.apache.tsfile.read.query.dataset.QueryDataSet; import org.apache.tsfile.utils.ReadWriteIOUtils; import org.apache.tsfile.write.chunk.AlignedChunkWriterImpl; import org.apache.tsfile.write.chunk.ChunkWriterImpl; @@ -240,6 +246,118 @@ private File constructV3TsFile() throws IOException { return tsFile; } + @Test + public void testSplitAlignedValueChunkWithUndecodedPagesPreservesValues() throws Exception { + assertAlignedValueChunkSplitPreservesValues(2); + } + + @Test + public void testSplitAlignedValueChunkSpanningTimePartitionsPreservesValues() throws Exception { + // A page that covers two time partitions has to be decoded and written once per partition. The + // partition ranges are located by binary search, so this also checks that no point is dropped + // or duplicated at the partition boundary. + assertAlignedValueChunkSplitPreservesValues(4); + } + + private void assertAlignedValueChunkSplitPreservesValues(final int maxNumberOfPointsInPage) + throws Exception { + final File sourceTsFile = + new File("split-table-aligned-values-" + maxNumberOfPointsInPage + "-source.tsfile"); + final File targetTsFile = + new File("split-table-aligned-values-" + maxNumberOfPointsInPage + "-target.tsfile"); + final IDeviceID deviceID = new StringArrayDeviceID("table1", "tagA"); + final int pointsPerPartition = 2; + final int partitionCount = 2; + final long baseTime = + TimePartitionUtils.getTimePartitionSlot(1_700_000_000_000L).getStartTime(); + final long timePartitionInterval = TimePartitionUtils.getTimePartitionInterval(); + final int pointCount = pointsPerPartition * partitionCount; + final long[] times = new long[pointCount]; + final long[] values = new long[pointCount]; + + final int previousMaxNumberOfPointsInPage = + TSFileDescriptor.getInstance().getConfig().getMaxNumberOfPointsInPage(); + try { + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(maxNumberOfPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + try (final TsFileIOWriter writer = new TsFileIOWriter(sourceTsFile)) { + writer.setSchema(createSchema()); + writer.startChunkGroup(deviceID); + final AlignedChunkWriterImpl chunkWriter = + new AlignedChunkWriterImpl( + Collections.singletonList(new MeasurementSchema("s1", TSDataType.INT64))); + for (int partition = 0; partition < partitionCount; partition++) { + for (int i = 0; i < pointsPerPartition; i++) { + final int index = partition * pointsPerPartition + i; + times[index] = baseTime + partition * timePartitionInterval + i; + values[index] = index; + chunkWriter.getTimeChunkWriter().write(times[index]); + chunkWriter.getValueChunkWriterByIndex(0).write(times[index], values[index], false); + } + } + chunkWriter.writeToFileWriter(writer); + writer.endChunkGroup(); + writer.endFile(); + } + + final List emittedChunkDataList = new ArrayList<>(); + new TsFileSplitter( + sourceTsFile, + tsFileData -> { + if (tsFileData instanceof ChunkData) { + emittedChunkDataList.add((ChunkData) tsFileData); + } + return true; + }) + .splitTsFileByDataPartition(); + + Assert.assertEquals(partitionCount, emittedChunkDataList.size()); + if (targetTsFile.exists()) { + Assert.assertTrue(targetTsFile.delete()); + } + try (final TsFileIOWriter writer = new TsFileIOWriter(targetTsFile)) { + writer.setSchema(createSchema()); + writer.startChunkGroup(deviceID); + for (final ChunkData chunkData : emittedChunkDataList) { + writeSerializedChunkDataToWriter(chunkData, writer); + } + writer.endChunkGroup(); + writer.endFile(); + } + + try (final TsFileReader reader = new TsFileReader(targetTsFile)) { + final QueryDataSet dataSet = + reader.query( + QueryExpression.create( + Collections.singletonList(new Path(deviceID, "s1", true)), null)); + int index = 0; + while (dataSet.hasNext()) { + final RowRecord row = dataSet.next(); + Assert.assertEquals(times[index], row.getTimestamp()); + final Field field = row.getField(0); + Assert.assertNotNull(field); + Assert.assertEquals(values[index], field.getLongV()); + index++; + } + Assert.assertEquals(pointCount, index); + } + } finally { + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(previousMaxNumberOfPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + if (targetTsFile.exists()) { + Assert.assertTrue(targetTsFile.delete()); + } + } + } + @Test public void testSplitTableTimeOnlyAlignedChunk() throws Exception { final File sourceTsFile = new File("split-table-time-only-source.tsfile"); From 13144b54f57c3e6384d9a95bc3d2e8d4fcf13379 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 11:38:45 +0800 Subject: [PATCH 07/18] Reduce allocations and copies when splitting a TsFile for LOAD --- .../plan/node/load/LoadTsFilePieceNode.java | 7 +- .../load/splitter/ChunkData.java | 50 ++++++++++---- .../load/splitter/ChunkPayloadRef.java | 68 +++++++++++++++---- .../load/splitter/TsFileSplitter.java | 8 ++- .../splitter/ChunkDataDirectWriteTest.java | 25 +++++++ 5 files changed, 128 insertions(+), 30 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/load/LoadTsFilePieceNode.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/load/LoadTsFilePieceNode.java index 402c7afc8ae5..745643678bae 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/load/LoadTsFilePieceNode.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/load/LoadTsFilePieceNode.java @@ -27,6 +27,7 @@ import org.apache.iotdb.db.i18n.DataNodeQueryMessages; import org.apache.iotdb.db.queryengine.plan.analyze.IAnalysis; import org.apache.iotdb.db.queryengine.plan.planner.plan.node.WritePlanNode; +import org.apache.iotdb.db.storageengine.load.splitter.ChunkData; import org.apache.iotdb.db.storageengine.load.splitter.TsFileData; import org.apache.tsfile.exception.NotImplementedException; @@ -220,7 +221,8 @@ public String toString() { + '}'; } - public static class ByteBufferInputStream extends InputStream { + public static class ByteBufferInputStream extends InputStream + implements ChunkData.ByteBufferSliceSource { private final ByteBuffer buffer; public ByteBufferInputStream(ByteBuffer buffer) { @@ -245,7 +247,8 @@ public int read(byte[] b, int off, int len) { return toRead; } - public ByteBuffer read(int length) { + @Override + public ByteBuffer readSlice(int length) { if (length < 0 || length > buffer.remaining()) { throw new IllegalArgumentException( DataNodeQueryMessages.INVALID_LENGTH_FOR_SLICING + length); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java index c35e41897146..31f058f7c89b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java @@ -44,7 +44,14 @@ public interface ChunkData extends TsFileData { ByteBuffer EMPTY_BUFFER = ByteBuffer.allocate(0); - byte[] EMPTY_BYTES = new byte[0]; + + /** + * An input stream over a {@link ByteBuffer} that can expose the next bytes as a slice instead of + * copying them into a new array. + */ + interface ByteBufferSliceSource { + ByteBuffer readSlice(int length); + } // ------------------------------------------------------------------------- // Metadata & Status @@ -168,9 +175,28 @@ static void serializeChunkPayload( // 2. Write inline payload bytes ReadWriteIOUtils.write(false, stream); // isRef = false - byte[] payload = extractPayloadBytes(data, ref); - ReadWriteIOUtils.write(payload.length, stream); - stream.write(payload); + final int payloadLength = (int) inlinePayloadSize(data, ref); + ReadWriteIOUtils.write(payloadLength, stream); + if (data != null && data.hasRemaining()) { + writeByteBuffer(stream, data); + } else if (ref != null) { + ref.writePayloadTo(stream); + } + } + + private static void writeByteBuffer(final DataOutputStream stream, final ByteBuffer data) + throws IOException { + if (data.hasArray() && !data.isReadOnly()) { + stream.write(data.array(), data.arrayOffset() + data.position(), data.remaining()); + return; + } + final ByteBuffer duplicate = data.duplicate(); + final byte[] buffer = new byte[Math.min(duplicate.remaining(), 8192)]; + while (duplicate.hasRemaining()) { + final int length = Math.min(duplicate.remaining(), buffer.length); + duplicate.get(buffer, 0, length); + stream.write(buffer, 0, length); + } } /** Deserializes payload, appending to {@code refs} if referenced externally. */ @@ -193,18 +219,14 @@ static ByteBuffer deserializeChunkPayload(InputStream stream, List file.length()) { + final File file = locatePayloadFile(); + try (final RandomAccessFile input = new RandomAccessFile(file, "r")) { + final byte[] payload = new byte[(int) size]; + input.seek(offset); + input.readFully(payload); + return payload; + } catch (final EOFException e) { + throw new ChunkPayloadUnavailableException( + String.format( + StorageEngineMessages.EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_EOF_8743387D, + file.getAbsolutePath(), + offset), + e); + } catch (final IOException | ArithmeticException | NegativeArraySizeException e) { throw new ChunkPayloadUnavailableException( String.format( StorageEngineMessages - .EXCEPTION_THE_CHUNK_PAYLOAD_OF_ARG_BYTES_AT_OFFSET_ARG_IS_LONGER_THAN_THE_STAGED_FILE_ARG_14081256, - size, - offset, - file.getAbsolutePath())); + .EXCEPTION_FAILED_TO_READ_BACK_THE_STAGED_PIECE_FILE_ARG_ARG_3F54CB90, + file.getAbsolutePath(), + e.getMessage()), + e); } + } + + /** + * Streams the referenced bytes into {@code stream} without materializing them in a byte array. + * Used when a payload has to be inlined into a request but is still stored in the staged file. + */ + public void writePayloadTo(final DataOutputStream stream) throws IOException { + final File file = locatePayloadFile(); + if (size == 0) { + return; + } + final byte[] buffer = new byte[(int) Math.min(size, 8192L)]; try (final RandomAccessFile input = new RandomAccessFile(file, "r")) { - final byte[] payload = new byte[(int) size]; input.seek(offset); - input.readFully(payload); - return payload; + long remaining = size; + while (remaining > 0) { + final int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); + if (read < 0) { + throw new EOFException(); + } + stream.write(buffer, 0, read); + remaining -= read; + } } catch (final EOFException e) { throw new ChunkPayloadUnavailableException( String.format( @@ -148,6 +175,23 @@ public byte[] readPayload() { } } + private File locatePayloadFile() { + final File file = locateFile(); + // Checked against the file before the buffer is allocated: without it a reference to bytes past + // the end of the staged file would still allocate its full size and fail later, and a huge size + // would be allocated only to be thrown away. + if (offset + size > file.length()) { + throw new ChunkPayloadUnavailableException( + String.format( + StorageEngineMessages + .EXCEPTION_THE_CHUNK_PAYLOAD_OF_ARG_BYTES_AT_OFFSET_ARG_IS_LONGER_THAN_THE_STAGED_FILE_ARG_14081256, + size, + offset, + file.getAbsolutePath())); + } + return file; + } + /** * Resolves the staged file this reference points at. * diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java index 16109c64de2d..9913566a7bbe 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java @@ -480,16 +480,18 @@ private void consumeAllAlignedChunkData( } } for (AlignedChunkData chunkData : chunkDataMap.keySet()) { - chunkData.endChunk(); timePartitionSlots.add(chunkData.getTimePartitionSlot()); if (deletions.isEmpty() && timePartitionSlots.size() > CONFIG.getLoadTsFileSpiltPartitionMaxSize()) { + // Check before encoding: the chunk that crosses the limit does not have to be turned into + // standard chunks only to be rejected. throw new LoadFileException( String.format( StorageEngineMessages .STORAGE_EXCEPTION_TIME_PARTITION_SLOTS_SIZE_IS_GREATER_THAN_S_D076F78E, CONFIG.getLoadTsFileSpiltPartitionMaxSize())); } + chunkData.endChunk(); if (Boolean.FALSE.equals(consumer.apply(chunkData))) { throw new IllegalStateException( String.format( @@ -549,7 +551,9 @@ private Pair decodePage( if (isAligned) { TimePageReader timePageReader = new TimePageReader(pageHeader, pageData, timeDecoder); long[] times = timePageReader.getNextTimeBatch(); - return new Pair<>(times, new Object[times.length]); + // The values of an aligned time page are never used: the value columns are decoded by + // decodeValuePage. Returning null avoids allocating a page-sized array of nulls. + return new Pair<>(times, null); } valueDecoder.reset(); diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java index 8db10eaf1b57..515b46668393 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java @@ -21,6 +21,7 @@ import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot; import org.apache.iotdb.commons.utils.TimePartitionUtils; +import org.apache.iotdb.db.queryengine.plan.planner.plan.node.load.LoadTsFilePieceNode; import org.apache.iotdb.db.queryengine.plan.scheduler.load.ChunkOffsetCalculator; import org.apache.iotdb.db.storageengine.load.splitter.ChunkData.ChunkLayout; @@ -68,6 +69,7 @@ import static org.apache.tsfile.common.constant.TsFileConstant.VALUE_COLUMN_MASK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -78,6 +80,29 @@ public class ChunkDataDirectWriteTest { private static final int DEFAULT_POINT_COUNT = 100; private static final IDeviceID DEFAULT_DEVICE = new StringArrayDeviceID("root", "sg", "d1"); + @Test + public void testInlineChunkPayloadIsDeserializedAsSlice() throws Exception { + final byte[] payload = new byte[] {1, 2, 3, 4, 5}; + final ByteBuffer serialized = ByteBuffer.allocate(Byte.BYTES + Integer.BYTES + payload.length); + serialized.put((byte) 0); // not a reference + serialized.putInt(payload.length); + serialized.put(payload); + serialized.flip(); + + final List refs = new ArrayList<>(); + final ByteBuffer deserialized = + ChunkData.deserializeChunkPayload( + new LoadTsFilePieceNode.ByteBufferInputStream(serialized), refs); + + assertTrue(refs.isEmpty()); + assertEquals(payload.length, deserialized.remaining()); + // The payload stays inside the assembled piece body instead of being copied into a new array. + assertSame(serialized.array(), deserialized.array()); + for (int i = 0; i < payload.length; i++) { + assertEquals(payload[i], deserialized.get(i)); + } + } + // ------------------------------------------------------------------------- // Direct Write (Bypass SerDe) Tests // ------------------------------------------------------------------------- From 068ad13263ad5ae987dfb771ef5b2ef5b4c65b46 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 11:48:04 +0800 Subject: [PATCH 08/18] Cover the LOAD split partition limit and non-array payload copy --- .../load/TsFileSplitterTest.java | 24 +++++++++++++++++++ .../splitter/ChunkDataDirectWriteTest.java | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java index 897b8f2074fd..1ed8fed13a6d 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java @@ -24,6 +24,7 @@ import org.apache.iotdb.commons.utils.TimePartitionUtils; import org.apache.iotdb.db.conf.IoTDBConfig; import org.apache.iotdb.db.conf.IoTDBDescriptor; +import org.apache.iotdb.db.exception.load.LoadFileException; import com.sun.management.ThreadMXBean; import org.apache.tsfile.common.conf.TSFileConfig; @@ -106,6 +107,29 @@ public void testSplitV3TsFile() throws Exception { } } + @Test + public void testSplitRejectsMoreTimePartitionsThanTheConfiguredLimit() throws Exception { + final File sourceTsFile = new File("split-partition-limit-source.tsfile"); + final IoTDBConfig config = IoTDBDescriptor.getInstance().getConfig(); + final int originalPartitionSlotsLimit = config.getLoadTsFileSpiltPartitionMaxSize(); + try { + config.setLoadTsFileSpiltPartitionMaxSize(2); + writeTableTsFileWithOnePointPerTimePartition(sourceTsFile, 3); + + try { + new TsFileSplitter(sourceTsFile, tsFileData -> true).splitTsFileByDataPartition(); + Assert.fail("expected the splitter to reject more time partitions than the limit"); + } catch (final LoadFileException e) { + // expected: the third partition crosses the configured limit + } + } finally { + config.setLoadTsFileSpiltPartitionMaxSize(originalPartitionSlotsLimit); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + } + } + @Test public void testSplitAlignedChunkAcrossManyTimePartitionsDecodesEachPageOnce() throws Exception { // One point per time partition in a single chunk group makes every value page span as many diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java index 515b46668393..a675924f4e5a 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java @@ -54,6 +54,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.IOException; @@ -109,6 +110,28 @@ public void testInlineChunkPayloadIsDeserializedAsSlice() throws Exception { // An entire non-aligned chunk reaches the writer untouched, with no serialize/deserialize round // trip in between + @Test + public void testNonArrayChunkPayloadUsesBlockCopyPath() throws Exception { + final ByteBuffer payload = ByteBuffer.allocateDirect(10_000); + for (int i = 0; i < payload.capacity(); i++) { + payload.put((byte) i); + } + payload.flip(); + + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (final DataOutputStream output = new DataOutputStream(bytes)) { + ChunkData.serializeChunkPayload(output, payload, null, true); + } + + final ByteBuffer deserialized = + ChunkData.deserializeChunkPayload( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())), new ArrayList<>()); + assertEquals(payload.remaining(), deserialized.remaining()); + for (int i = 0; i < payload.remaining(); i++) { + assertEquals(payload.get(i), deserialized.get(i)); + } + } + @Test public void testNonAlignedChunkDataCanWriteWithoutSerdeRoundTrip() throws Exception { final NonAlignedChunkData chunkData = createNonAlignedChunkData(DEFAULT_DEVICE, "s1"); From 7686fcf62251691ff05cde9073c5ca0f2be9dc87 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 12:19:19 +0800 Subject: [PATCH 09/18] Rename the LOAD write buffer size config and allocate it once --- .../apache/iotdb/db/conf/IoTDBDescriptor.java | 4 +- .../write/BufferedFileChannelOutput.java | 85 +++++-------------- .../apache/iotdb/db/conf/PropertiesTest.java | 9 +- 3 files changed, 27 insertions(+), 71 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java index 21e9c6d973fc..b22ad2dc9692 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBDescriptor.java @@ -2624,7 +2624,7 @@ private void loadLoadTsFileProps(TrimProperties properties) { conf.setLoadTsFileWriteBufferSize( Integer.parseInt( properties.getProperty( - "load_tsfile_write_buffer_size", + "load_tsfile_write_buffer_size_in_byte", String.valueOf(conf.getLoadTsFileWriteBufferSize())))); conf.setLoadTsFileAllowedDirs( @@ -2779,7 +2779,7 @@ private void loadLoadTsFileHotModifiedProp(TrimProperties properties) throws IOE conf.setLoadTsFileWriteBufferSize( Integer.parseInt( properties.getProperty( - "load_tsfile_write_buffer_size", + "load_tsfile_write_buffer_size_in_byte", String.valueOf(conf.getLoadTsFileWriteBufferSize())))); conf.setLoadTsFileAllowedDirs( diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java index 98cd293ae688..404a6ab2f46a 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java @@ -29,14 +29,13 @@ import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; -import java.util.Arrays; import java.util.Objects; /** * A {@link TsFileOutput} that buffers writes and hands them to the {@link FileChannel} once the - * configured buffer size is reached. The buffer is allocated at half of that size and grows on - * demand, and it is flushed by {@link #force()}, {@link #flush()}, {@link #truncate(long)}, a seek - * to another position, or {@link #close()}. + * configured buffer size is reached. The buffer is allocated at its full configured size when the + * output is created, and it is flushed by {@link #force()}, {@link #flush()}, {@link + * #truncate(long)}, a seek to another position, or {@link #close()}. */ public final class BufferedFileChannelOutput extends OutputStream implements TsFileOutput { @@ -45,9 +44,10 @@ public final class BufferedFileChannelOutput extends OutputStream implements TsF private final FileChannel channel; private final int bufferSize; - private final DynamicBuffer buffer; + private final byte[] buffer; + private int bufferLength; - /** Creates an output that buffers {@code load_tsfile_write_buffer_size} bytes. */ + /** Creates an output that buffers {@code load_tsfile_write_buffer_size_in_byte} bytes. */ public BufferedFileChannelOutput(final FileChannel channel) { this(channel, configuredBufferSize()); } @@ -66,7 +66,7 @@ public BufferedFileChannelOutput(final FileChannel channel, final int bufferSize DataNodeMiscMessages.EXCEPTION_BUFFER_SIZE_SHOULD_BE_GREATER_THAN_0_BB68B5C4); } this.bufferSize = bufferSize; - this.buffer = new DynamicBuffer(Math.max(1, bufferSize / 2)); + this.buffer = new byte[bufferSize]; } private static int configuredBufferSize() { @@ -81,10 +81,10 @@ public synchronized void write(final int b) throws IOException { @Override public synchronized void write(final byte b) throws IOException { - if (buffer.size() == bufferSize) { + if (bufferLength == bufferSize) { flushBuffer(); } - buffer.write(b); + buffer[bufferLength++] = b; } @Override @@ -105,10 +105,11 @@ public synchronized void write(final byte[] bytes, final int offset, final int l writeDirect(bytes, offset, length); return; } - if (length > bufferSize - buffer.size()) { + if (length > bufferSize - bufferLength) { flushBuffer(); } - buffer.write(bytes, offset, length); + System.arraycopy(bytes, offset, buffer, bufferLength, length); + bufferLength += length; } @Override @@ -124,10 +125,11 @@ public synchronized void write(final ByteBuffer src) throws IOException { } return; } - if (length > bufferSize - buffer.size()) { + if (length > bufferSize - bufferLength) { flushBuffer(); } - buffer.write(src); + src.get(buffer, bufferLength, length); + bufferLength += length; } private void writeDirect(final byte[] bytes, final int offset, final int length) @@ -140,25 +142,25 @@ private void writeDirect(final byte[] bytes, final int offset, final int length) /** Hands the buffered bytes to the channel, keeping the previous writes in order. */ private void flushBuffer() throws IOException { - if (buffer.size() == 0) { + if (bufferLength == 0) { return; } - final ByteBuffer wrap = ByteBuffer.wrap(buffer.array(), 0, buffer.size()); + final ByteBuffer wrap = ByteBuffer.wrap(buffer, 0, bufferLength); while (wrap.hasRemaining()) { channel.write(wrap); } - buffer.clear(); + bufferLength = 0; } @Override public synchronized long getPosition() throws IOException { // The buffered bytes are already logically written, so a position query must not flush them. - return channel.position() + buffer.size(); + return channel.position() + bufferLength; } /** Moves the output to the given absolute position, flushing first when the position changes. */ synchronized void position(final long offset) throws IOException { - if (offset == channel.position() + buffer.size()) { + if (offset == channel.position() + bufferLength) { return; } flushBuffer(); @@ -196,51 +198,4 @@ public synchronized void close() throws IOException { channel.close(); } } - - /** A byte array that grows on demand and is only ever written to by its owner. */ - private static final class DynamicBuffer { - - private byte[] data; - private int size; - - private DynamicBuffer(final int initialCapacity) { - this.data = new byte[initialCapacity]; - } - - private void ensureCapacity(final int minCapacity) { - if (minCapacity > data.length) { - data = Arrays.copyOf(data, Math.max(data.length * 2, minCapacity)); - } - } - - private void write(final byte b) { - ensureCapacity(size + 1); - data[size++] = b; - } - - private void write(final byte[] src, final int offset, final int length) { - ensureCapacity(size + length); - System.arraycopy(src, offset, data, size, length); - size += length; - } - - private void write(final ByteBuffer src) { - final int length = src.remaining(); - ensureCapacity(size + length); - src.get(data, size, length); - size += length; - } - - private int size() { - return size; - } - - private byte[] array() { - return data; - } - - private void clear() { - size = 0; - } - } } diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java index f7a1eca4f7cb..0fe910f1d6b9 100755 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/PropertiesTest.java @@ -80,15 +80,15 @@ public void testLoadTsFileWriteBufferSizeDefaultsAndReloads() throws Exception { Assert.assertEquals(1024 * 1024, new IoTDBConfig().getLoadTsFileWriteBufferSize()); final TrimProperties properties = new TrimProperties(); - properties.setProperty("load_tsfile_write_buffer_size", "4096"); + properties.setProperty("load_tsfile_write_buffer_size_in_byte", "4096"); descriptor.loadProperties(properties); Assert.assertEquals(4096, descriptor.getConfig().getLoadTsFileWriteBufferSize()); - properties.setProperty("load_tsfile_write_buffer_size", "8192"); + properties.setProperty("load_tsfile_write_buffer_size_in_byte", "8192"); descriptor.loadHotModifiedProps(properties); Assert.assertEquals(8192, descriptor.getConfig().getLoadTsFileWriteBufferSize()); - properties.setProperty("load_tsfile_write_buffer_size", "0"); + properties.setProperty("load_tsfile_write_buffer_size_in_byte", "0"); Assert.assertThrows( QueryProcessException.class, () -> descriptor.loadHotModifiedProps(properties)); Assert.assertEquals( @@ -97,7 +97,8 @@ public void testLoadTsFileWriteBufferSizeDefaultsAndReloads() throws Exception { descriptor.getConfig().getLoadTsFileWriteBufferSize()); } finally { final TrimProperties properties = new TrimProperties(); - properties.setProperty("load_tsfile_write_buffer_size", Integer.toString(originalValue)); + properties.setProperty( + "load_tsfile_write_buffer_size_in_byte", Integer.toString(originalValue)); descriptor.loadHotModifiedProps(properties); } } From 4988738fb1bcea12032a3a1162a76f1f9df26e30 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 13:36:23 +0800 Subject: [PATCH 10/18] Reduce LOAD split allocations and stabilize migration test - Reuse a ByteBuffer view for buffered flushes and a per-thread payload buffer. - Group buffered aligned pages once instead of scanning them per chunk. - Harden staged payload bounds and cover payloads larger than the scratch buffer. - Throttle the migration IT LOAD so it cannot finish before migration submission. --- .../env/cluster/config/MppDataNodeConfig.java | 9 ++ .../remote/config/RemoteDataNodeConfig.java | 6 ++ .../iotdb/itbase/env/DataNodeConfig.java | 2 + .../it/IoTDBLoadTsFileRegionMigrationIT.java | 26 +++++ .../load/splitter/AlignedChunkData.java | 102 ++++++++---------- .../BatchedAlignedValueChunkData.java | 24 +++-- .../load/splitter/ChunkData.java | 7 +- .../load/splitter/ChunkPayloadRef.java | 15 ++- .../load/splitter/ChunkStreamBuffer.java | 32 ++++++ .../load/splitter/TsFileSplitter.java | 3 +- .../write/BufferedFileChannelOutput.java | 26 +++-- .../load/LoadTsFileManagerTest.java | 23 ++++ 12 files changed, 187 insertions(+), 88 deletions(-) create mode 100644 iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java index 90174941e54e..0d72bf2cf4ff 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java @@ -120,6 +120,15 @@ public DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( return this; } + @Override + public DataNodeConfig setLoadWriteThroughputBytesPerSecond( + double loadWriteThroughputBytesPerSecond) { + properties.setProperty( + "load_write_throughput_bytes_per_second", + String.valueOf(loadWriteThroughputBytesPerSecond)); + return this; + } + @Override public DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval) { properties.setProperty( diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java index 95e4b23b3e73..c9bfd637c186 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java @@ -76,6 +76,12 @@ public DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( return this; } + @Override + public DataNodeConfig setLoadWriteThroughputBytesPerSecond( + double loadWriteThroughputBytesPerSecond) { + return this; + } + @Override public DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval) { return this; diff --git a/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java index 6f65e18cdeac..415dd1412b38 100644 --- a/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java @@ -46,6 +46,8 @@ DataNodeConfig setLoadTsFileTabletConversionBatchMemorySizeInBytes( DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( long loadActiveListeningCheckIntervalSeconds); + DataNodeConfig setLoadWriteThroughputBytesPerSecond(double loadWriteThroughputBytesPerSecond); + DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval); DataNodeConfig setEnableMQTTService(boolean enableMQTTService); diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java index f45a42df17da..50845d3f4fcc 100644 --- a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java @@ -83,6 +83,12 @@ public class IoTDBLoadTsFileRegionMigrationIT { private static final int CONFIG_NODE_NUM = 1; private static final int DATA_NODE_NUM = 3; + /** + * Keep the LOAD blocked after its first piece until the migration has been submitted, so the two + * operations cannot silently run one after the other on a fast machine. + */ + private static final double THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND = 1; + private File tmpDir; @BeforeClass @@ -93,6 +99,10 @@ public static void setUpClass() { EnvFactory.getEnv().getConfig().getCommonConfig().setDatanodeMemoryProportion("1:10:1:1:1:0"); EnvFactory.getEnv().getConfig().getCommonConfig().setTargetChunkPointNum(1_000); EnvFactory.getEnv().getConfig().getCommonConfig().setMaxNumberOfPointsInPage(500); + EnvFactory.getEnv() + .getConfig() + .getDataNodeConfig() + .setLoadWriteThroughputBytesPerSecond(THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND); EnvFactory.getEnv() .getConfig() .getCommonConfig() @@ -169,6 +179,10 @@ public void testLoadWhileARegionMigrates() throws Exception { "migrate region " + migration[0] + " from " + migration[1] + " to " + migration[2]); } + // The migration procedure is submitted asynchronously. Resume the throttled LOAD now so the + // two operations overlap instead of running one after the other. + restoreLoadWriteThroughput(); + // The LOAD may follow the migration with a refreshed route or fall back to the tablet path, // but it must not report a failure to the client. final Exception loadFailure = load.get(15, TimeUnit.MINUTES); @@ -181,10 +195,22 @@ public void testLoadWhileARegionMigrates() throws Exception { awaitPointCount(statement, writtenPointCount); } } finally { + try { + restoreLoadWriteThroughput(); + } catch (final Exception ignored) { + // Preserve the original failure; the cluster is discarded by tearDownClass. + } loader.shutdownNow(); } } + private static void restoreLoadWriteThroughput() throws SQLException { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement()) { + statement.execute("set configuration \"load_write_throughput_bytes_per_second\"=\"-1\""); + } + } + /** * Waits until the LOAD has allocated a data region of the database, and returns the region, one * of the DataNodes that replicates it and a DataNode that does not, in that order. diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index 0b8609cf400b..b19b55f329cb 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -55,7 +55,6 @@ import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -329,39 +328,37 @@ public void endChunk() { } protected void encodeAndBuildChunks() throws IOException, PageException { - // One aligned ChunkData may accumulate pages from several physical Chunks before it is - // consumed. Encode every Chunk represented by the current buffers, while leaving Chunks that - // were sealed by an earlier endChunk() untouched. - final Set bufferedChunkIndexes = new LinkedHashSet<>(); + // Group the buffered pages by chunk once; scanning the whole page list once per chunk turns + // wide aligned tables into O(columns * pages). + final Map> pagesByChunkIndex = new LinkedHashMap<>(); for (final PageBuffer pageBuffer : pageBuffers) { - bufferedChunkIndexes.add(pageBuffer.chunkIndex); + pagesByChunkIndex + .computeIfAbsent(pageBuffer.chunkIndex, ignored -> new ArrayList<>()) + .add(pageBuffer); } - // A chunk whose pages were never split only needs its page headers and data copied. Building it - // through AlignedChunkWriterImpl would encode every page again and then copy the whole chunk - // out - // of the writer's growable buffer, so its peak cost is roughly four times the chunk size. + // Fast-path: assemble chunks that need no splitting directly, avoiding re-encoding and its + // buffer amplification. final Map directlyBuiltChunks = new LinkedHashMap<>(); - for (final int chunkIndex : bufferedChunkIndexes) { + for (final Map.Entry> entry : pagesByChunkIndex.entrySet()) { + final int chunkIndex = entry.getKey(); final Chunk chunk = buildChunkFromUndecodedPages( - chunkIndex, chunkHeaderList.get(chunkIndex), - chunkIndex == 0 ? TIME_COLUMN_MASK : VALUE_COLUMN_MASK); + chunkIndex == 0 ? TIME_COLUMN_MASK : VALUE_COLUMN_MASK, + entry.getValue()); if (chunk != null) { directlyBuiltChunks.put(chunkIndex, chunk); } } final AlignedChunkWriterImpl writer = - directlyBuiltChunks.size() == bufferedChunkIndexes.size() - ? null - : createAlignedChunkWriter(); + directlyBuiltChunks.size() == pagesByChunkIndex.size() ? null : createAlignedChunkWriter(); if (writer != null) { writePageBuffersToWriter(writer, directlyBuiltChunks.keySet()); } - for (final int chunkIndex : bufferedChunkIndexes) { + for (final int chunkIndex : pagesByChunkIndex.keySet()) { final Chunk directChunk = directlyBuiltChunks.get(chunkIndex); if (directChunk != null) { entireChunks.add(directChunk); @@ -401,17 +398,15 @@ protected void encodeAndBuildChunks() throws IOException, PageException { * the caller then falls back to {@link AlignedChunkWriterImpl}. */ protected Chunk buildChunkFromUndecodedPages( - final int chunkIndex, final ChunkHeader sourceHeader, final int mask) throws IOException { + final ChunkHeader sourceHeader, final int mask, final List chunkPages) + throws IOException { // A time chunk header carries VECTOR, and its pages use TimeStatistics rather than the // LongStatistics that Statistics.getStatsByType(INT64) would create. final TSDataType statisticsType = sourceHeader.getDataType(); Statistics statistics = null; int totalSize = 0; int pageCount = 0; - for (final PageBuffer page : pageBuffers) { - if (page.chunkIndex != chunkIndex) { - continue; - } + for (final PageBuffer page : chunkPages) { if (page.needDecode || page.pageHeader == null || page.pageHeader.getStatistics() == null) { return null; } @@ -431,8 +426,8 @@ protected Chunk buildChunkFromUndecodedPages( final ByteBuffer encodedData = ByteBuffer.allocate(totalSize); final OutputStream headerStream = new ByteBufferOutputStream(encodedData); - for (final PageBuffer page : pageBuffers) { - if (page.chunkIndex != chunkIndex || page.pageData.remaining() == 0) { + for (final PageBuffer page : chunkPages) { + if (page.pageData.remaining() == 0) { continue; } page.pageHeader.serializeTo(headerStream); @@ -510,11 +505,7 @@ private AlignedChunkWriterImpl createAlignedChunkWriter() { private void writePageBuffersToWriter( final AlignedChunkWriterImpl writer, final Set directlyBuiltChunkIndexes) throws IOException, PageException { - // The partition bounds do not depend on the point and TimePartitionUtils derives them with - // BigInteger arithmetic, so resolve them once per ChunkData instead of once per buffered point: - // a page that covers thousands of time partitions otherwise costs O(points * partitions) bound - // computations and the matching allocation churn. The end time is the overflow-safe one, so the - // last representable partition still keeps its upper bound. + // Cache partition bounds: resolving them per point performs BigInteger work and allocations. final long partitionStart = timePartitionSlot.getStartTime(); final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); for (final PageBuffer page : pageBuffers) { @@ -535,58 +526,55 @@ private void writePageBuffersToWriter( } continue; } - // Times inside a page are sorted, so the points of this partition are one contiguous range. - // Locating that range with two binary searches avoids rescanning the whole page for every - // partition: a page that covers thousands of partitions used to cost O(points * partitions) - // bound comparisons even after the bounds themselves were cached. - final int from = lowerBound(page.timeBatch, partitionStart); - final int to = upperBound(page.timeBatch, partitionEnd); + // Timestamps within a page are monotonically increasing; binary-search the contiguous slice + // for this partition, starting the upper bound at the lower bound. + final int from = lowerBound(page.timeBatch, 0, page.timeBatch.length, partitionStart); + final int to = upperBound(page.timeBatch, from, page.timeBatch.length, partitionEnd); + if (from >= to) { + continue; + } if (page.valueBatch == null) { for (int i = from; i < to; i++) { writer.writeTime(page.timeBatch[i]); } - if (from < to) { - writer.sealCurrentTimePage(); - } + writer.sealCurrentTimePage(); } else { final TSDataType dataType = chunkHeaderList.get(page.chunkIndex).getDataType(); for (int i = from; i < to; i++) { writeValueToAlignedChunkWriter( writer, page.timeBatch[i], page.valueBatch[i], dataType, page.chunkIndex - 1); } - if (from < to) { - writer.sealCurrentValuePage(page.chunkIndex - 1); - } + writer.sealCurrentValuePage(page.chunkIndex - 1); } } } - private static int lowerBound(final long[] times, final long key) { - int low = 0; - int high = times.length; - while (low < high) { - final int mid = (low + high) >>> 1; + private static int lowerBound(final long[] times, final int low, final int high, final long key) { + int left = low; + int right = high; + while (left < right) { + final int mid = (left + right) >>> 1; if (times[mid] < key) { - low = mid + 1; + left = mid + 1; } else { - high = mid; + right = mid; } } - return low; + return left; } - private static int upperBound(final long[] times, final long key) { - int low = 0; - int high = times.length; - while (low < high) { - final int mid = (low + high) >>> 1; + private static int upperBound(final long[] times, final int low, final int high, final long key) { + int left = low; + int right = high; + while (left < right) { + final int mid = (left + right) >>> 1; if (times[mid] <= key) { - low = mid + 1; + left = mid + 1; } else { - high = mid; + right = mid; } } - return low; + return left; } private void writeValueToAlignedChunkWriter( diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java index 8aba3582d6cc..56bff063d705 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java @@ -35,6 +35,11 @@ import java.io.IOException; import java.io.Serializable; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import static org.apache.tsfile.common.constant.TsFileConstant.VALUE_COLUMN_MASK; @@ -69,11 +74,22 @@ protected void encodeAndBuildChunks() throws IOException, PageException { // partition and equals the start when interval == 1. final long endTime = TimePartitionUtils.getTimePartitionEndTime(startTime); + // Group the buffered pages by chunk once; scanning the whole page list once per chunk turns + // batched aligned tables into O(columns * pages). + final Map> pagesByChunkIndex = new LinkedHashMap<>(); + for (final PageBuffer pageBuffer : pageBuffers) { + pagesByChunkIndex + .computeIfAbsent(pageBuffer.chunkIndex, ignored -> new ArrayList<>()) + .add(pageBuffer); + } + // Process each standalone value chunk for (int i = 0; i < chunkHeaderList.size(); i++) { final ChunkHeader chunkHeader = chunkHeaderList.get(i); + final List chunkPages = + pagesByChunkIndex.getOrDefault(i, Collections.emptyList()); final Chunk directlyBuiltChunk = - buildChunkFromUndecodedPages(i, chunkHeader, VALUE_COLUMN_MASK); + buildChunkFromUndecodedPages(chunkHeader, VALUE_COLUMN_MASK, chunkPages); if (directlyBuiltChunk != null) { entireChunks.add(directlyBuiltChunk); dataSize += @@ -102,11 +118,7 @@ protected void encodeAndBuildChunks() throws IOException, PageException { // untouched writer would produce a zero-point page whose data size does not match the bytes // actually written, and the resulting TsFile could not be parsed back. boolean hasBufferedData = false; - for (PageBuffer page : pageBuffers) { - if (page.chunkIndex != i) { - continue; - } - + for (PageBuffer page : chunkPages) { if (!page.needDecode) { // An undecoded page may carry no data at all (for aligned series an all-null column is // stored as an empty page). Writing its page header alone would produce a chunk whose diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java index 31f058f7c89b..ed5308f0b18d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java @@ -191,7 +191,7 @@ private static void writeByteBuffer(final DataOutputStream stream, final ByteBuf return; } final ByteBuffer duplicate = data.duplicate(); - final byte[] buffer = new byte[Math.min(duplicate.remaining(), 8192)]; + final byte[] buffer = ChunkStreamBuffer.get(); while (duplicate.hasRemaining()) { final int length = Math.min(duplicate.remaining(), buffer.length); duplicate.get(buffer, 0, length); @@ -220,9 +220,8 @@ static ByteBuffer deserializeChunkPayload(InputStream stream, List file.length()) { + // Check against the file before allocating: the subtraction form cannot overflow even for an + // offset or size close to Long.MAX_VALUE. + if (offset < 0 || size < 0 || size > file.length() - offset) { throw new ChunkPayloadUnavailableException( String.format( StorageEngineMessages diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java new file mode 100644 index 000000000000..99ee0c0fe55b --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java @@ -0,0 +1,32 @@ +/* + * 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.db.storageengine.load.splitter; + +/** Per-thread scratch buffer for payloads that are not backed by a heap array. */ +final class ChunkStreamBuffer { + + private static final ThreadLocal BUFFER = ThreadLocal.withInitial(() -> new byte[8192]); + + static byte[] get() { + return BUFFER.get(); + } + + private ChunkStreamBuffer() {} +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java index 9913566a7bbe..0b36175b37ae 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java @@ -551,8 +551,7 @@ private Pair decodePage( if (isAligned) { TimePageReader timePageReader = new TimePageReader(pageHeader, pageData, timeDecoder); long[] times = timePageReader.getNextTimeBatch(); - // The values of an aligned time page are never used: the value columns are decoded by - // decodeValuePage. Returning null avoids allocating a page-sized array of nulls. + // Aligned time pages carry no value array; return null to avoid a dummy Object[]. return new Pair<>(times, null); } diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java index 404a6ab2f46a..784da91a2dbe 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java @@ -45,6 +45,10 @@ public final class BufferedFileChannelOutput extends OutputStream implements TsF private final FileChannel channel; private final int bufferSize; private final byte[] buffer; + + /** Reused wrapper over {@link #buffer} so each flush does not allocate a new ByteBuffer. */ + private final ByteBuffer bufferView; + private int bufferLength; /** Creates an output that buffers {@code load_tsfile_write_buffer_size_in_byte} bytes. */ @@ -67,6 +71,7 @@ public BufferedFileChannelOutput(final FileChannel channel, final int bufferSize } this.bufferSize = bufferSize; this.buffer = new byte[bufferSize]; + this.bufferView = ByteBuffer.wrap(buffer); } private static int configuredBufferSize() { @@ -120,9 +125,7 @@ public synchronized void write(final ByteBuffer src) throws IOException { } if (length >= bufferSize) { flushBuffer(); - while (src.hasRemaining()) { - channel.write(src); - } + writeFully(src); return; } if (length > bufferSize - bufferLength) { @@ -134,10 +137,7 @@ public synchronized void write(final ByteBuffer src) throws IOException { private void writeDirect(final byte[] bytes, final int offset, final int length) throws IOException { - final ByteBuffer wrap = ByteBuffer.wrap(bytes, offset, length); - while (wrap.hasRemaining()) { - channel.write(wrap); - } + writeFully(ByteBuffer.wrap(bytes, offset, length)); } /** Hands the buffered bytes to the channel, keeping the previous writes in order. */ @@ -145,13 +145,17 @@ private void flushBuffer() throws IOException { if (bufferLength == 0) { return; } - final ByteBuffer wrap = ByteBuffer.wrap(buffer, 0, bufferLength); - while (wrap.hasRemaining()) { - channel.write(wrap); - } + bufferView.position(0).limit(bufferLength); + writeFully(bufferView); bufferLength = 0; } + private void writeFully(final ByteBuffer src) throws IOException { + while (src.hasRemaining()) { + channel.write(src); + } + } + @Override public synchronized long getPosition() throws IOException { // The buffered bytes are already logically written, so a position query must not flush them. diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java index c98b3afa1958..dc58ca3eb41c 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java @@ -410,6 +410,29 @@ public void testChunkPayloadRefRefusesToReadBeyondTheStagedFile() throws Excepti } } + @Test + public void testChunkPayloadRefStreamsPayloadsLargerThanItsScratchBuffer() throws Exception { + // Stage a piece first so the shared staging directory follows this test's tempDir, then append + // a payload larger than the 8 KiB scratch buffer to the staged file. + final LoadTsFileManager manager = new LoadTsFileManager(dataRegion); + final LoadTsFileConsensusNode piece = stagedPiece("large-payload-load", laidOutChunk()); + manager.writePiece(piece); + final File staged = new File(tempDir, piece.getPieceRefs().get(0).getRelativePath()); + final long offset = staged.length(); + final byte[] payload = new byte[20_000]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i * 31); + } + Files.write(staged.toPath(), payload, StandardOpenOption.APPEND); + + final ChunkPayloadRef ref = + new ChunkPayloadRef(staged.getAbsolutePath(), offset, payload.length); + final ByteArrayOutputStream output = new ByteArrayOutputStream(payload.length); + ref.writePayloadTo(new DataOutputStream(output)); + + assertArrayEquals(payload, output.toByteArray()); + } + /** * A PREPARE of a task that is still missing a piece must fail instead of sealing the file. * From 6f2ad103acf5a3a9d1f7bf8db853648f4685bb2d Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 14:30:57 +0800 Subject: [PATCH 11/18] Drop unnecessary LOAD output locks and stream buffer reuse The buffered output is used by a single writer, and the per-call 8 KiB payload buffer does not justify a thread-local allocation in the I/O path. --- .../load/splitter/ChunkData.java | 2 +- .../load/splitter/ChunkPayloadRef.java | 2 +- .../load/splitter/ChunkStreamBuffer.java | 32 ------------------- .../write/BufferedFileChannelOutput.java | 25 ++++++++------- 4 files changed, 15 insertions(+), 46 deletions(-) delete mode 100644 iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java index ed5308f0b18d..7deb07b9bd79 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkData.java @@ -191,7 +191,7 @@ private static void writeByteBuffer(final DataOutputStream stream, final ByteBuf return; } final ByteBuffer duplicate = data.duplicate(); - final byte[] buffer = ChunkStreamBuffer.get(); + final byte[] buffer = new byte[Math.min(duplicate.remaining(), 8192)]; while (duplicate.hasRemaining()) { final int length = Math.min(duplicate.remaining(), buffer.length); duplicate.get(buffer, 0, length); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java index c77addaf9cdb..439355bd179e 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java @@ -145,7 +145,7 @@ public void writePayloadTo(final DataOutputStream stream) throws IOException { if (size == 0) { return; } - final byte[] buffer = ChunkStreamBuffer.get(); + final byte[] buffer = new byte[(int) Math.min(size, 8192L)]; try (final RandomAccessFile input = new RandomAccessFile(file, "r")) { input.seek(offset); long remaining = size; diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java deleted file mode 100644 index 99ee0c0fe55b..000000000000 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkStreamBuffer.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.db.storageengine.load.splitter; - -/** Per-thread scratch buffer for payloads that are not backed by a heap array. */ -final class ChunkStreamBuffer { - - private static final ThreadLocal BUFFER = ThreadLocal.withInitial(() -> new byte[8192]); - - static byte[] get() { - return BUFFER.get(); - } - - private ChunkStreamBuffer() {} -} diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java index 784da91a2dbe..cff31eaa5802 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java @@ -36,6 +36,8 @@ * configured buffer size is reached. The buffer is allocated at its full configured size when the * output is created, and it is flushed by {@link #force()}, {@link #flush()}, {@link * #truncate(long)}, a seek to another position, or {@link #close()}. + * + *

This class is not thread-safe; callers must serialize access to one instance. */ public final class BufferedFileChannelOutput extends OutputStream implements TsFileOutput { @@ -80,12 +82,12 @@ private static int configuredBufferSize() { } @Override - public synchronized void write(final int b) throws IOException { + public void write(final int b) throws IOException { write((byte) b); } @Override - public synchronized void write(final byte b) throws IOException { + public void write(final byte b) throws IOException { if (bufferLength == bufferSize) { flushBuffer(); } @@ -93,13 +95,12 @@ public synchronized void write(final byte b) throws IOException { } @Override - public synchronized void write(final byte[] bytes) throws IOException { + public void write(final byte[] bytes) throws IOException { write(bytes, 0, bytes.length); } @Override - public synchronized void write(final byte[] bytes, final int offset, final int length) - throws IOException { + public void write(final byte[] bytes, final int offset, final int length) throws IOException { Objects.checkFromIndexSize(offset, length, bytes.length); if (length == 0) { return; @@ -118,7 +119,7 @@ public synchronized void write(final byte[] bytes, final int offset, final int l } @Override - public synchronized void write(final ByteBuffer src) throws IOException { + public void write(final ByteBuffer src) throws IOException { final int length = src.remaining(); if (length == 0) { return; @@ -157,13 +158,13 @@ private void writeFully(final ByteBuffer src) throws IOException { } @Override - public synchronized long getPosition() throws IOException { + public long getPosition() throws IOException { // The buffered bytes are already logically written, so a position query must not flush them. return channel.position() + bufferLength; } /** Moves the output to the given absolute position, flushing first when the position changes. */ - synchronized void position(final long offset) throws IOException { + void position(final long offset) throws IOException { if (offset == channel.position() + bufferLength) { return; } @@ -177,25 +178,25 @@ public OutputStream wrapAsStream() { } @Override - public synchronized void flush() throws IOException { + public void flush() throws IOException { flushBuffer(); } @Override - public synchronized void force() throws IOException { + public void force() throws IOException { flushBuffer(); // Pass false to fsync file content without blocking on OS metadata modifications channel.force(false); } @Override - public synchronized void truncate(final long size) throws IOException { + public void truncate(final long size) throws IOException { flushBuffer(); channel.truncate(size); } @Override - public synchronized void close() throws IOException { + public void close() throws IOException { try { flushBuffer(); } finally { From f6a73dc5e1277ddccd82a1ee1fc52c6fb5c809d1 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 16:50:19 +0800 Subject: [PATCH 12/18] Wait for region migration activation before resuming a throttled LOAD Keep the LOAD paused until show regions reports the migrated region as Adding or Removing, and assert it is still running at that point, so the migration IT cannot pass with the two operations serialized. --- .../it/IoTDBLoadTsFileRegionMigrationIT.java | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java index 50845d3f4fcc..cdaff188d788 100644 --- a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java @@ -19,6 +19,7 @@ package org.apache.iotdb.db.it; +import org.apache.iotdb.commons.cluster.RegionStatus; import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant; import org.apache.iotdb.consensus.ConsensusFactory; import org.apache.iotdb.it.env.EnvFactory; @@ -84,8 +85,8 @@ public class IoTDBLoadTsFileRegionMigrationIT { private static final int DATA_NODE_NUM = 3; /** - * Keep the LOAD blocked after its first piece until the migration has been submitted, so the two - * operations cannot silently run one after the other on a fast machine. + * Keep the LOAD blocked after it dispatches a piece until the migration becomes active, so the + * two operations cannot silently run one after the other on a fast machine. */ private static final double THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND = 1; @@ -179,8 +180,11 @@ public void testLoadWhileARegionMigrates() throws Exception { "migrate region " + migration[0] + " from " + migration[1] + " to " + migration[2]); } - // The migration procedure is submitted asynchronously. Resume the throttled LOAD now so the - // two operations overlap instead of running one after the other. + // The migration procedure is submitted asynchronously. Keep the LOAD throttled until the + // procedure has actually started changing the region replicas, then resume it so the two + // operations overlap instead of running one after the other. + awaitRegionMigrationActive(migration[0]); + Assert.assertFalse("the LOAD finished while the migration was starting", load.isDone()); restoreLoadWriteThroughput(); // The LOAD may follow the migration with a refreshed route or fall back to the tablet path, @@ -249,6 +253,33 @@ private static int[] awaitMigratableRegion() { return migration.get(); } + /** Waits until the migration procedure has started changing the region's replicas. */ + private static void awaitRegionMigrationActive(final int regionId) { + Awaitility.await() + .atMost(2, TimeUnit.MINUTES) + .pollInterval(200, TimeUnit.MILLISECONDS) + .ignoreExceptions() + .untilAsserted( + () -> { + try (final Connection connection = EnvFactory.getEnv().getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("show regions")) { + while (resultSet.next()) { + if (!"DataRegion".equals(resultSet.getString(ColumnHeaderConstant.TYPE)) + || regionId != resultSet.getInt(ColumnHeaderConstant.REGION_ID)) { + continue; + } + final String status = resultSet.getString(ColumnHeaderConstant.STATUS); + if (RegionStatus.Adding.getStatus().equals(status) + || RegionStatus.Removing.getStatus().equals(status)) { + return; + } + } + } + Assert.fail("region " + regionId + " has not started migrating"); + }); + } + /** Waits until the migrated region left the source DataNode and reached the destination. */ private static void awaitRegionMigrated( final int regionId, final int sourceDataNode, final int destinationDataNode) { From e0982d360e318b23d7d67246d6952fd8b8423038 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Tue, 20 Oct 2026 17:01:04 +0800 Subject: [PATCH 13/18] Keep the migration LOAD throttle in the integration test Set the DataNode property through the MPP config before the cluster starts, so the regression test adds no test-framework API. --- .../env/cluster/config/MppDataNodeConfig.java | 9 --------- .../env/remote/config/RemoteDataNodeConfig.java | 6 ------ .../apache/iotdb/itbase/env/DataNodeConfig.java | 2 -- .../db/it/IoTDBLoadTsFileRegionMigrationIT.java | 17 +++++++++++++---- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java index 0d72bf2cf4ff..90174941e54e 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppDataNodeConfig.java @@ -120,15 +120,6 @@ public DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( return this; } - @Override - public DataNodeConfig setLoadWriteThroughputBytesPerSecond( - double loadWriteThroughputBytesPerSecond) { - properties.setProperty( - "load_write_throughput_bytes_per_second", - String.valueOf(loadWriteThroughputBytesPerSecond)); - return this; - } - @Override public DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval) { properties.setProperty( diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java index c9bfd637c186..95e4b23b3e73 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteDataNodeConfig.java @@ -76,12 +76,6 @@ public DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( return this; } - @Override - public DataNodeConfig setLoadWriteThroughputBytesPerSecond( - double loadWriteThroughputBytesPerSecond) { - return this; - } - @Override public DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval) { return this; diff --git a/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java b/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java index 415dd1412b38..6f65e18cdeac 100644 --- a/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/itbase/env/DataNodeConfig.java @@ -46,8 +46,6 @@ DataNodeConfig setLoadTsFileTabletConversionBatchMemorySizeInBytes( DataNodeConfig setLoadActiveListeningCheckIntervalSeconds( long loadActiveListeningCheckIntervalSeconds); - DataNodeConfig setLoadWriteThroughputBytesPerSecond(double loadWriteThroughputBytesPerSecond); - DataNodeConfig setCompactionScheduleInterval(long compactionScheduleInterval); DataNodeConfig setEnableMQTTService(boolean enableMQTTService); diff --git a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java index cdaff188d788..9de078017e9a 100644 --- a/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/db/it/IoTDBLoadTsFileRegionMigrationIT.java @@ -23,6 +23,7 @@ import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant; import org.apache.iotdb.consensus.ConsensusFactory; import org.apache.iotdb.it.env.EnvFactory; +import org.apache.iotdb.it.env.cluster.config.MppDataNodeConfig; import org.apache.iotdb.it.framework.IoTDBTestRunner; import org.apache.iotdb.it.utils.TsFileGenerator; import org.apache.iotdb.itbase.category.ClusterIT; @@ -52,6 +53,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -90,6 +92,16 @@ public class IoTDBLoadTsFileRegionMigrationIT { */ private static final double THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND = 1; + /** Sets the LOAD write throughput in the DataNode properties before the cluster starts. */ + private static void setLoadWriteThroughput(final double bytesPerSecond) { + final MppDataNodeConfig dataNodeConfig = + (MppDataNodeConfig) EnvFactory.getEnv().getConfig().getDataNodeConfig(); + final Properties properties = new Properties(); + properties.setProperty( + "load_write_throughput_bytes_per_second", String.valueOf(bytesPerSecond)); + dataNodeConfig.updateProperties(properties); + } + private File tmpDir; @BeforeClass @@ -100,10 +112,7 @@ public static void setUpClass() { EnvFactory.getEnv().getConfig().getCommonConfig().setDatanodeMemoryProportion("1:10:1:1:1:0"); EnvFactory.getEnv().getConfig().getCommonConfig().setTargetChunkPointNum(1_000); EnvFactory.getEnv().getConfig().getCommonConfig().setMaxNumberOfPointsInPage(500); - EnvFactory.getEnv() - .getConfig() - .getDataNodeConfig() - .setLoadWriteThroughputBytesPerSecond(THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND); + setLoadWriteThroughput(THROTTLED_LOAD_WRITE_THROUGHPUT_BYTES_PER_SECOND); EnvFactory.getEnv() .getConfig() .getCommonConfig() From 4b6e08c1e2a1c47a055000c48a069db23094816c Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Wed, 21 Oct 2026 11:31:41 +0800 Subject: [PATCH 14/18] Harden LOAD TsFile chunk assembly and decouple the buffered output from node config * Check every copied page size and the assembled chunk length before handing out a chunk, and fall back to re-encoding when they do not match * Pass the buffer size to BufferedFileChannelOutput instead of letting it read the DataNode configuration, and drop its unreachable default size * Document why the splitter may binary-search the points of a partition: chunk group writers reject out-of-order timestamps, so a page is always ascending --- .../load/splitter/AlignedChunkData.java | 47 +++++++++++++++---- .../write/BufferedFileChannelOutput.java | 16 +------ .../write/TsFilePrecalculatedChunkWriter.java | 15 +++++- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index b19b55f329cb..29a16b9057b1 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -52,6 +52,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.BufferOverflowException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -417,7 +418,12 @@ protected Chunk buildChunkFromUndecodedPages( statistics = Statistics.getStatsByType(statisticsType); } statistics.mergeStatistics(page.pageHeader.getStatistics()); - totalSize += page.pageHeader.getSerializedPageSize(); + try { + totalSize = Math.addExact(totalSize, page.pageHeader.getSerializedPageSize()); + } catch (final ArithmeticException e) { + // A chunk that does not fit in one buffer cannot be assembled here: re-encode it instead. + return null; + } pageCount++; } if (statistics == null) { @@ -426,12 +432,35 @@ protected Chunk buildChunkFromUndecodedPages( final ByteBuffer encodedData = ByteBuffer.allocate(totalSize); final OutputStream headerStream = new ByteBufferOutputStream(encodedData); - for (final PageBuffer page : chunkPages) { - if (page.pageData.remaining() == 0) { - continue; + try { + for (final PageBuffer page : chunkPages) { + if (page.pageData.remaining() == 0) { + continue; + } + // The pages are copied as they were read, so each page header has to serialize back to + // exactly the bytes getSerializedPageSize() accounted for. Verify that instead of trusting + // it: a header that serializes longer would overflow the buffer, and a shorter one would + // leave a chunk whose declared data size does not describe its pages. + final int expectedPageSize = page.pageHeader.getSerializedPageSize(); + final int pageDataSize = page.pageData.remaining(); + if (expectedPageSize < pageDataSize || expectedPageSize > encodedData.remaining()) { + return null; + } + final int pageStart = encodedData.position(); + page.pageHeader.serializeTo(headerStream); + if (encodedData.position() - pageStart != expectedPageSize - pageDataSize + || pageDataSize > encodedData.remaining()) { + return null; + } + encodedData.put(page.pageData.duplicate()); } - page.pageHeader.serializeTo(headerStream); - encodedData.put(page.pageData.duplicate()); + } catch (final BufferOverflowException e) { + // Defensive only: the checks above already keep every write within the allocated size. + return null; + } + if (encodedData.position() != totalSize) { + // Never hand out a chunk whose header advertises more bytes than it carries. + return null; } encodedData.flip(); // A chunk with one page is normally written with ONLY_ONE_PAGE_CHUNK_HEADER, whose page @@ -526,8 +555,10 @@ private void writePageBuffersToWriter( } continue; } - // Timestamps within a page are monotonically increasing; binary-search the contiguous slice - // for this partition, starting the upper bound at the lower bound. + // Timestamps within a page are strictly increasing: a chunk group writer rejects an + // out-of-order point (see Aligned/NonAlignedChunkGroupWriterImpl#checkIsHistoryData), so the + // points of this partition are always a contiguous slice and the upper bound can start at the + // lower bound. final int from = lowerBound(page.timeBatch, 0, page.timeBatch.length, partitionStart); final int to = upperBound(page.timeBatch, from, page.timeBatch.length, partitionEnd); if (from >= to) { diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java index cff31eaa5802..946b99e5c70d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/BufferedFileChannelOutput.java @@ -19,7 +19,6 @@ package org.apache.tsfile.write; -import org.apache.iotdb.db.conf.IoTDBDescriptor; import org.apache.iotdb.db.i18n.DataNodeMiscMessages; import org.apache.iotdb.db.i18n.StorageEngineMessages; @@ -33,7 +32,7 @@ /** * A {@link TsFileOutput} that buffers writes and hands them to the {@link FileChannel} once the - * configured buffer size is reached. The buffer is allocated at its full configured size when the + * buffer size the caller passes in is reached. The buffer is allocated at its full size when the * output is created, and it is flushed by {@link #force()}, {@link #flush()}, {@link * #truncate(long)}, a seek to another position, or {@link #close()}. * @@ -41,9 +40,6 @@ */ public final class BufferedFileChannelOutput extends OutputStream implements TsFileOutput { - /** The buffer size used when the configured size is not positive. */ - private static final int DEFAULT_BUFFER_SIZE = 1024 * 1024; - private final FileChannel channel; private final int bufferSize; private final byte[] buffer; @@ -53,11 +49,6 @@ public final class BufferedFileChannelOutput extends OutputStream implements TsF private int bufferLength; - /** Creates an output that buffers {@code load_tsfile_write_buffer_size_in_byte} bytes. */ - public BufferedFileChannelOutput(final FileChannel channel) { - this(channel, configuredBufferSize()); - } - /** * Creates an output that buffers the given number of bytes. * @@ -76,11 +67,6 @@ public BufferedFileChannelOutput(final FileChannel channel, final int bufferSize this.bufferView = ByteBuffer.wrap(buffer); } - private static int configuredBufferSize() { - final int configured = IoTDBDescriptor.getInstance().getConfig().getLoadTsFileWriteBufferSize(); - return configured > 0 ? configured : DEFAULT_BUFFER_SIZE; - } - @Override public void write(final int b) throws IOException { write((byte) b); diff --git a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java index ed4156974f26..8ceee739d323 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java @@ -19,6 +19,7 @@ package org.apache.tsfile.write; +import org.apache.iotdb.db.conf.IoTDBDescriptor; import org.apache.iotdb.db.i18n.StorageEngineMessages; import org.apache.tsfile.common.conf.TSFileConfig; @@ -87,6 +88,14 @@ public class TsFilePrecalculatedChunkWriter implements AutoCloseable { private long maxPhysicalFileSize; private boolean sealed; + /** + * The buffer size of the staged file output, from {@code load_tsfile_write_buffer_size_in_byte}. + * Only the caller knows the configuration; the output itself stays independent of it. + */ + private static int loadTsFileWriteBufferSize() { + return IoTDBDescriptor.getInstance().getConfig().getLoadTsFileWriteBufferSize(); + } + public TsFilePrecalculatedChunkWriter(final File file) throws IOException { this.file = Objects.requireNonNull(file, StorageEngineMessages.EXCEPTION_FILE_CANNOT_BE_NULL_29A83D70); @@ -96,7 +105,8 @@ public TsFilePrecalculatedChunkWriter(final File file) throws IOException { file.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)); + StandardOpenOption.TRUNCATE_EXISTING), + loadTsFileWriteBufferSize()); this.sealed = false; startFile(); this.maxPhysicalFileSize = out.getPosition(); @@ -119,7 +129,8 @@ public TsFilePrecalculatedChunkWriter( this.out = new BufferedFileChannelOutput( Objects.requireNonNull( - channel, StorageEngineMessages.EXCEPTION_CHANNEL_CANNOT_BE_NULL_0F79E0FB)); + channel, StorageEngineMessages.EXCEPTION_CHANNEL_CANNOT_BE_NULL_0F79E0FB), + loadTsFileWriteBufferSize()); this.sealed = sealed; this.maxPhysicalFileSize = channel.size(); LOGGER.info( From d025e1a27cff1fa885d3f692d43dc79c91411a28 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Wed, 21 Oct 2026 12:54:04 +0800 Subject: [PATCH 15/18] Keep all-null pages aligned when the LOAD splitter cuts chunks Address the review comments on the TsFile piece splitter and fix the page alignment defects they exposed. ChunkPayloadRef returns before locating the staged file when the payload is empty, and reports the staged file path, the requested length, the offset and the position reached when a read runs past the end of the file. An empty page (an all-null column, serialized as a single 0 varint) has to keep its page slot in both the copied and the re-encoded chunk: the aligned reader pairs the pages of a value chunk with the pages of its time chunk by position, so dropping one shifts every later value of that column onto the wrong timestamps. Only a chunk without any page that carries data keeps the zero-page layout of an all-null column. A page that is not empty but has no statistics belongs to a single-page chunk, where the statistics live in the chunk metadata; the previous check rejected both cases and re-encoded chunks that merely contained an empty page. decodeValuePage tested the serialized size of a page against 0, which is never true for an empty page (it serializes to one byte) and made such pages decode to an empty value array, failing the split with an ArrayIndexOutOfBoundsException when the page had to be split at a time partition boundary. An empty page now expands to one null per time of the page. --- .../iotdb/db/i18n/StorageEngineMessages.java | 6 +- .../iotdb/db/i18n/StorageEngineMessages.java | 5 +- .../load/splitter/AlignedChunkData.java | 65 +++++++++++-- .../load/splitter/ChunkPayloadRef.java | 49 ++++++---- .../load/splitter/TsFileSplitter.java | 17 +--- .../LoadTsFileManagerSplitCoverageTest.java | 93 ++++++++++++++++++- .../load/LoadTsFileManagerTest.java | 13 +++ .../splitter/ChunkDataDirectWriteTest.java | 43 +++++++++ 8 files changed, 243 insertions(+), 48 deletions(-) diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/StorageEngineMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/StorageEngineMessages.java index d38533131912..c19790974c52 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/StorageEngineMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/StorageEngineMessages.java @@ -81,8 +81,10 @@ private StorageEngineMessages() {} "The LOAD snapshot progress file {} cannot be read back: {}"; public static final String MESSAGE_LOAD_CONSENSUS_PIECE_CHECKSUM_MISMATCH_CF261675 = "LOAD consensus piece checksum mismatch, loadId: %s, pieceIndex: %d"; - public static final String EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_EOF_8743387D = - "Unexpected end of file when reading staged piece %s at offset %s."; + public static final String + EXCEPTION_UNEXPECTED_END_OF_FILE_WHEN_READING_THE_STAGED_PIECE_ARG_ARG_BYTES_WERE_REQUESTED_FROM_OFFSET_ARG_BUT_THE_FILE_ENDED_AT_POSITION_ARG_3F9CA32B = + "Unexpected end of file when reading the staged piece %s: %d bytes were requested from " + + "offset %d, but the file ended at position %d."; public static final String EXCEPTION_STAGED_PIECE_FILE_ARG_IS_MISSING_AND_CANNOT_BE_READ_BACK_4F62F9C6 = "Staged piece file %s is missing and cannot be read back."; public static final String EXCEPTION_LOAD_CONSENSUS_INVALID_CHUNK_PAYLOAD_REF_PATH_ARG_OFFSET_ARG_SIZE_ARG_6B70A61B = diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/StorageEngineMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/StorageEngineMessages.java index 53b828387b55..32352ae33e17 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/StorageEngineMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/StorageEngineMessages.java @@ -80,8 +80,9 @@ private StorageEngineMessages() {} "LOAD 快照进度文件 {} 无法读取:{}"; public static final String MESSAGE_LOAD_CONSENSUS_PIECE_CHECKSUM_MISMATCH_CF261675 = "LOAD 共识分片校验和不一致,loadId: %s,pieceIndex: %d"; - public static final String EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_EOF_8743387D = - "读取已暂存的分片 %s 时意外到达文件末尾,offset: %s。"; + public static final String + EXCEPTION_UNEXPECTED_END_OF_FILE_WHEN_READING_THE_STAGED_PIECE_ARG_ARG_BYTES_WERE_REQUESTED_FROM_OFFSET_ARG_BUT_THE_FILE_ENDED_AT_POSITION_ARG_3F9CA32B = + "读取已暂存的分片 %s 时意外到达文件末尾:期望读取 %d 字节(起始偏移 %d),但文件在位置 %d 处结束。"; public static final String EXCEPTION_STAGED_PIECE_FILE_ARG_IS_MISSING_AND_CANNOT_BE_READ_BACK_4F62F9C6 = "已暂存的分片文件 %s 不存在,无法回读。"; public static final String EXCEPTION_LOAD_CONSENSUS_INVALID_CHUNK_PAYLOAD_REF_PATH_ARG_OFFSET_ARG_SIZE_ARG_6B70A61B = diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index 29a16b9057b1..606d1cccd414 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -55,6 +55,7 @@ import java.nio.BufferOverflowException; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; @@ -395,8 +396,8 @@ protected void encodeAndBuildChunks() throws IOException, PageException { /** * Reassembles a chunk from pages that were stored without decoding. Returns {@code null} when the - * chunk has a decoded page, an empty page whose statistics cannot be merged, or no page at all; - * the caller then falls back to {@link AlignedChunkWriterImpl}. + * chunk has a decoded page, a non-empty page without statistics, or no page with data at all; the + * caller then falls back to {@link AlignedChunkWriterImpl}. */ protected Chunk buildChunkFromUndecodedPages( final ChunkHeader sourceHeader, final int mask, final List chunkPages) @@ -407,13 +408,31 @@ protected Chunk buildChunkFromUndecodedPages( Statistics statistics = null; int totalSize = 0; int pageCount = 0; + int emptyPageCount = 0; for (final PageBuffer page : chunkPages) { - if (page.needDecode || page.pageHeader == null || page.pageHeader.getStatistics() == null) { + if (page.needDecode) { + // A decoded page is kept as points instead of bytes, so it cannot be copied as it is. + return null; + } + if (page.pageHeader == null) { return null; } if (page.pageData.remaining() == 0) { + // An empty page (an aligned series stores an all-null column as an empty page) has no data + // and no statistics, but it still occupies one page slot. The aligned reader pairs the + // pages of a value chunk with the pages of its time chunk by position, so dropping it would + // shift every later page of the value chunk onto the wrong timestamps. It is kept as the + // single 0 varint it serializes to, unless the whole chunk is empty: an all-null chunk + // keeps the zero-page layout instead. + emptyPageCount++; continue; } + // A non-empty page without statistics belongs to a single-page chunk: the statistics of such + // a chunk live in the chunk metadata, not in the page header, and cannot be rebuilt from the + // page bytes. Such a chunk cannot be assembled here and has to go through the writer path. + if (page.pageHeader.getStatistics() == null) { + return null; + } if (statistics == null) { statistics = Statistics.getStatsByType(statisticsType); } @@ -429,14 +448,18 @@ protected Chunk buildChunkFromUndecodedPages( if (statistics == null) { return null; } + pageCount += emptyPageCount; + try { + // Each kept empty page is exactly the one byte of the 0 varint that represents it. + totalSize = Math.addExact(totalSize, emptyPageCount); + } catch (final ArithmeticException e) { + return null; + } final ByteBuffer encodedData = ByteBuffer.allocate(totalSize); final OutputStream headerStream = new ByteBufferOutputStream(encodedData); try { for (final PageBuffer page : chunkPages) { - if (page.pageData.remaining() == 0) { - continue; - } // The pages are copied as they were read, so each page header has to serialize back to // exactly the bytes getSerializedPageSize() accounted for. Verify that instead of trusting // it: a header that serializes longer would overflow the buffer, and a shorter one would @@ -537,14 +560,18 @@ private void writePageBuffersToWriter( // Cache partition bounds: resolving them per point performs BigInteger work and allocations. final long partitionStart = timePartitionSlot.getStartTime(); final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); + final Set chunkIndexesWithData = chunkIndexesWithData(directlyBuiltChunkIndexes); for (final PageBuffer page : pageBuffers) { if (directlyBuiltChunkIndexes.contains(page.chunkIndex)) { continue; } if (!page.needDecode) { - // Skip empty pages: an aligned series stores an all-null column as an empty page, and - // writing its page header alone yields a chunk that cannot be parsed back. - if (page.pageData.remaining() == 0) { + // An empty page (an aligned series stores an all-null column as an empty page) has to be + // written whenever its chunk also has a page with data: the aligned reader pairs the pages + // of a value chunk with the pages of its time chunk by position, so dropping the page would + // shift every later page of the value chunk onto the wrong timestamps. A chunk that has no + // page with data at all keeps the zero-page layout of an all-null column instead. + if (page.pageData.remaining() == 0 && !chunkIndexesWithData.contains(page.chunkIndex)) { continue; } if (page.chunkIndex == 0) { @@ -580,6 +607,26 @@ private void writePageBuffersToWriter( } } + /** + * Chunk indexes written here that carry at least one point. Their empty pages are written too so + * that the page sequence of every value chunk stays aligned with the page sequence of the time + * chunk; a chunk without any point keeps the zero-page layout of an all-null column. + */ + private Set chunkIndexesWithData(final Set directlyBuiltChunkIndexes) { + final Set chunkIndexesWithData = new HashSet<>(); + for (final PageBuffer page : pageBuffers) { + if (directlyBuiltChunkIndexes.contains(page.chunkIndex)) { + continue; + } + final boolean hasData = + page.needDecode ? page.satisfiedLength > 0 : page.pageData.remaining() > 0; + if (hasData) { + chunkIndexesWithData.add(page.chunkIndex); + } + } + return chunkIndexesWithData; + } + private static int lowerBound(final long[] times, final int low, final int high, final long key) { int left = low; int right = high; diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java index 439355bd179e..89d7166c4cc8 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkPayloadRef.java @@ -112,19 +112,20 @@ public static ChunkPayloadRef deserializeFrom(final InputStream stream) throws I * outside of this DataNode's LOAD directories */ public byte[] readPayload() { + if (size == 0) { + // Nothing to read: an empty payload must not require the staged file to still exist. + return new byte[0]; + } final File file = locatePayloadFile(); try (final RandomAccessFile input = new RandomAccessFile(file, "r")) { final byte[] payload = new byte[Math.toIntExact(size)]; input.seek(offset); - input.readFully(payload); - return payload; - } catch (final EOFException e) { - throw new ChunkPayloadUnavailableException( - String.format( - StorageEngineMessages.EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_EOF_8743387D, - file.getAbsolutePath(), - offset), - e); + try { + input.readFully(payload); + return payload; + } catch (final EOFException e) { + throw newStagedFileEofException(file, input.getFilePointer(), e); + } } catch (final IOException | ArithmeticException e) { throw new ChunkPayloadUnavailableException( String.format( @@ -141,29 +142,26 @@ public byte[] readPayload() { * Used when a payload has to be inlined into a request but is still stored in the staged file. */ public void writePayloadTo(final DataOutputStream stream) throws IOException { - final File file = locatePayloadFile(); if (size == 0) { + // Nothing to copy: an empty payload must not require the staged file to still exist. return; } + final File file = locatePayloadFile(); final byte[] buffer = new byte[(int) Math.min(size, 8192L)]; try (final RandomAccessFile input = new RandomAccessFile(file, "r")) { input.seek(offset); long remaining = size; + long position = offset; while (remaining > 0) { final int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (read < 0) { - throw new EOFException(); + // The file shrank after it was checked: report the position the file actually ends at. + throw newStagedFileEofException(file, position, null); } stream.write(buffer, 0, read); remaining -= read; + position += read; } - } catch (final EOFException e) { - throw new ChunkPayloadUnavailableException( - String.format( - StorageEngineMessages.EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_EOF_8743387D, - file.getAbsolutePath(), - offset), - e); } catch (final IOException | ArithmeticException e) { throw new ChunkPayloadUnavailableException( String.format( @@ -175,6 +173,21 @@ public void writePayloadTo(final DataOutputStream stream) throws IOException { } } + private ChunkPayloadUnavailableException newStagedFileEofException( + final File file, final long position, final Throwable cause) { + final String message = + String.format( + StorageEngineMessages + .EXCEPTION_UNEXPECTED_END_OF_FILE_WHEN_READING_THE_STAGED_PIECE_ARG_ARG_BYTES_WERE_REQUESTED_FROM_OFFSET_ARG_BUT_THE_FILE_ENDED_AT_POSITION_ARG_3F9CA32B, + file.getAbsolutePath(), + size, + offset, + position); + return cause == null + ? new ChunkPayloadUnavailableException(message) + : new ChunkPayloadUnavailableException(message, cause); + } + private File locatePayloadFile() { final File file = locateFile(); // Check against the file before allocating: the subtraction form cannot overflow even for an diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java index 9a297ccdfb3d..edb23a148cb0 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java @@ -599,19 +599,6 @@ private void handleEmptyValueChunk( } } - /** - * handle empty page in aligned chunk, if uncompressedSize and compressedSize are both 0, and the - * statistics is null, then the page is empty. - * - * @param pageHeader page header - * @return true if the page is empty - */ - private boolean isEmptyPage(PageHeader pageHeader) { - return pageHeader.getUncompressedSize() == 0 - && pageHeader.getCompressedSize() == 0 - && pageHeader.getStatistics() == null; - } - private TsPrimitiveType[] decodeValuePage( TsFileSequenceReader reader, ChunkHeader chunkHeader, @@ -619,7 +606,9 @@ private TsPrimitiveType[] decodeValuePage( long[] times, Decoder valueDecoder) throws IOException { - if (pageHeader.getSerializedPageSize() == 0) { + // An empty page stands for an all-null slice of the column. It has to expand to one null per + // time of the page, otherwise the decoded values would not line up with the decoded times. + if (pageHeader.getUncompressedSize() == 0) { return new TsPrimitiveType[times.length]; } diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerSplitCoverageTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerSplitCoverageTest.java index a1c2cee8689b..7db4d160748c 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerSplitCoverageTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerSplitCoverageTest.java @@ -299,6 +299,56 @@ public void testAlignedMeasurementMatrix() throws Exception { 5_000L)); } + /** + * An aligned column whose values are all null in one page has to keep that page slot: the aligned + * reader pairs the pages of a value chunk with the pages of its time chunk by position, so a + * dropped page shifts every later value onto the wrong timestamp. Covers a page that is copied as + * it was read, a page that is split at a time partition boundary and a chunk that has to be + * re-encoded. + */ + @Test + public void testAllNullPageKeepsItsPageSlot() throws Exception { + final List copiedPages = new ArrayList<>(); + copiedPages.add(new long[][] {page(0, 9), page(10, 19), page(20, 29), page(100, 109)}); + final List splitPages = new ArrayList<>(); + splitPages.add(new long[][] {page(190, 209), page(210, 229)}); + // A page copied as it was read plus a page that has to be decoded: here the empty page is + // written + // by the re-encoding path, which has to keep it as well. + final List mixedPages = new ArrayList<>(); + mixedPages.add(new long[][] {page(0, 9), page(10, 19), page(90, 209)}); + + final List devices = new ArrayList<>(); + devices.add( + alignedDevice( + "null_page_copied", + ChunkOrder.CHUNK_MAJOR, + series("m0", TSDataType.INT64, NullMode.NONE, copiedPages), + series( + "m1", TSDataType.INT64, NullMode.NONE, copiedPages, new long[][] {page(10, 19)}))); + devices.add( + alignedDevice( + "null_page_split", + ChunkOrder.CHUNK_MAJOR, + series("m0", TSDataType.INT64, NullMode.NONE, splitPages), + series( + "m1", TSDataType.INT64, NullMode.NONE, splitPages, new long[][] {page(190, 209)}))); + devices.add( + alignedDevice( + "null_page_reencoded", + ChunkOrder.CHUNK_MAJOR, + series("m0", TSDataType.INT64, NullMode.NONE, mixedPages), + series( + "m1", TSDataType.INT64, NullMode.NONE, mixedPages, new long[][] {page(10, 19)}))); + runScenario( + new Scenario( + "all-null-page", + FINE_PARTITION_INTERVAL, + devices, + new DispatchPlan(2, false, false), + 400L)); + } + /** Non-aligned devices with 1..5 measurements, both chunk layouts and null-heavy series. */ @Test public void testNonAlignedMeasurementMatrix() throws Exception { @@ -1204,7 +1254,7 @@ private static List expectedPoints(final SeriesSpec series) { for (int chunkIndex = 0; chunkIndex < series.chunks.size(); chunkIndex++) { for (final long[] pageRange : series.chunks.get(chunkIndex)) { for (long time = pageRange[0]; time <= pageRange[1]; time++) { - if (series.nullMode.isNull(time, chunkIndex)) { + if (series.isNull(time, chunkIndex)) { continue; } expected.add(new Object[] {time, valueFor(series.dataType, time)}); @@ -1293,7 +1343,7 @@ private static void writeNonAlignedChunk( for (final long[] pageRange : series.chunks.get(chunkIndex)) { boolean wroteThisPage = false; for (long time = pageRange[0]; time <= pageRange[1]; time++) { - if (series.nullMode.isNull(time, chunkIndex)) { + if (series.isNull(time, chunkIndex)) { continue; } writeNonAlignedValue(chunkWriter, series.dataType, time, valueFor(series.dataType, time)); @@ -1335,7 +1385,7 @@ private static void writeAlignedChunkPages( series.dataType, time, valueFor(series.dataType, time), - series.nullMode.isNull(time, chunkIndex)); + series.isNull(time, chunkIndex)); } alignedWriter.write(time); } @@ -1639,10 +1689,15 @@ boolean isNull(final long time, final int chunkIndex) { } private static final class SeriesSpec { + static final long[][] NO_NULL_RANGES = new long[0][]; + final String measurement; final TSDataType dataType; final NullMode nullMode; + /** Inclusive time ranges that are null on top of {@link #nullMode}. */ + final long[][] nullRanges; + /** Outer list = chunks; inner list = pages; each page = [startTime, endTime] inclusive. */ final List chunks; @@ -1651,10 +1706,32 @@ private static final class SeriesSpec { final TSDataType dataType, final NullMode nullMode, final List chunks) { + this(measurement, dataType, nullMode, chunks, NO_NULL_RANGES); + } + + SeriesSpec( + final String measurement, + final TSDataType dataType, + final NullMode nullMode, + final List chunks, + final long[][] nullRanges) { this.measurement = measurement; this.dataType = dataType; this.nullMode = nullMode; this.chunks = chunks; + this.nullRanges = nullRanges; + } + + boolean isNull(final long time, final int chunkIndex) { + if (nullMode.isNull(time, chunkIndex)) { + return true; + } + for (final long[] range : nullRanges) { + if (time >= range[0] && time <= range[1]) { + return true; + } + } + return false; } } @@ -1745,6 +1822,16 @@ private static SeriesSpec series( return new SeriesSpec(measurement, dataType, nullMode, chunks); } + /** A series whose points inside {@code nullRanges} are written as nulls. */ + private static SeriesSpec series( + final String measurement, + final TSDataType dataType, + final NullMode nullMode, + final List chunks, + final long[][] nullRanges) { + return new SeriesSpec(measurement, dataType, nullMode, chunks, nullRanges); + } + private static DeviceSpec alignedDevice( final String name, final ChunkOrder chunkOrder, final SeriesSpec... series) { return new DeviceSpec(deviceId(name), true, chunkOrder, Arrays.asList(series)); diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java index 3e50e9638854..9c0060c5fa9f 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java @@ -440,6 +440,19 @@ public void testChunkPayloadRefStreamsPayloadsLargerThanItsScratchBuffer() throw assertArrayEquals(payload, output.toByteArray()); } + @Test + public void testEmptyChunkPayloadRefNeedsNoStagedFile() throws Exception { + // An empty payload owns no bytes, so it can be read back even after the staged file is gone: + // resolving a missing file first would fail a forward that has nothing to copy. + final File cleanedUp = new File(tempDir, "cleaned-up.tsfile"); + final ChunkPayloadRef empty = new ChunkPayloadRef(cleanedUp.getAbsolutePath(), 0L, 0L); + + assertArrayEquals(new byte[0], empty.readPayload()); + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + empty.writePayloadTo(new DataOutputStream(output)); + assertEquals(0, output.size()); + } + /** * A PREPARE of a task that is still missing a piece must fail instead of sealing the file. * diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java index a675924f4e5a..35ea08b459b6 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/splitter/ChunkDataDirectWriteTest.java @@ -28,6 +28,7 @@ import org.apache.tsfile.common.constant.TsFileConstant; import org.apache.tsfile.enums.TSDataType; import org.apache.tsfile.file.header.ChunkHeader; +import org.apache.tsfile.file.header.PageHeader; import org.apache.tsfile.file.metadata.IChunkMetadata; import org.apache.tsfile.file.metadata.IDeviceID; import org.apache.tsfile.file.metadata.StringArrayDeviceID; @@ -70,6 +71,7 @@ import static org.apache.tsfile.common.constant.TsFileConstant.VALUE_COLUMN_MASK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -248,6 +250,47 @@ public void testAlignedChunkEndBuildsOnlyCurrentPhysicalChunk() throws Exception assertEquals(VALUE_COLUMN_MASK, valueChunk.getHeader().getChunkType() & VALUE_COLUMN_MASK); } + // An empty page of an undecoded chunk (an all-null column) carries no statistics, but it still + // occupies a page slot: the aligned reader pairs the pages of a value chunk with the pages of its + // time chunk by position, so the page has to be copied as the single byte it serializes to + // instead of forcing the whole chunk through the re-encoding path + @Test + public void testEmptyUndecodedPageKeepsItsPageSlot() throws Exception { + final Statistics pageStatistics = Statistics.getStatsByType(TSDataType.VECTOR); + pageStatistics.update(1L); + pageStatistics.update(2L); + final PageHeader pageHeader = new PageHeader(8, 4, pageStatistics); + + final List pages = new ArrayList<>(); + pages.add( + new AlignedChunkData.PageBuffer(0, new PageHeader(0, 0, null), ByteBuffer.allocate(0))); + pages.add(new AlignedChunkData.PageBuffer(0, pageHeader, ByteBuffer.wrap(new byte[4]))); + + final Chunk chunk = + createAlignedTimeChunkData(DEFAULT_DEVICE) + .buildChunkFromUndecodedPages(createTimeChunkHeader(), TIME_COLUMN_MASK, pages); + + assertNotNull("an empty page must not force the chunk to be re-encoded", chunk); + assertEquals(2, chunk.getChunkStatistic().getCount()); + // One byte for the kept empty page plus the page that was read from the source file. + assertEquals(1 + pageHeader.getSerializedPageSize(), chunk.getData().remaining()); + } + + // A chunk whose pages are all empty keeps the zero-page layout of an all-null column + @Test + public void testAllEmptyUndecodedPagesStayAnEmptyChunk() throws Exception { + final List pages = new ArrayList<>(); + pages.add( + new AlignedChunkData.PageBuffer(0, new PageHeader(0, 0, null), ByteBuffer.allocate(0))); + pages.add( + new AlignedChunkData.PageBuffer(0, new PageHeader(0, 0, null), ByteBuffer.allocate(0))); + + assertNull( + "an all-null chunk has to keep the zero-page layout", + createAlignedTimeChunkData(DEFAULT_DEVICE) + .buildChunkFromUndecodedPages(createTimeChunkHeader(), TIME_COLUMN_MASK, pages)); + } + // ------------------------------------------------------------------------- // Time Partition Filtering Tests // ------------------------------------------------------------------------- From 34342908aac3c7554dc5428411cfc157a4ad355b Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Wed, 21 Oct 2026 16:31:17 +0800 Subject: [PATCH 16/18] Drop the throwaway chunk wrapper and the per-chunk index set consumeAllAlignedChunkData built a BatchedAlignedValueChunkData for every chunk data and then consumed the original objects (the map's keys), so the wrapper and its satisfied-length copy were allocated and discarded. A LinkedHashSet keeps the same insertion order and the same de-duplication without the wrapper; the copy constructors that only served it go away with it. writePageBuffersToWriter built a HashSet of the chunk indexes that carry data for every chunk it re-encodes. Only an empty page asks that question, so answer it by scanning the pages of that one chunk instead: no set, no boxing, and no work at all when the chunk has no empty page. --- .../load/splitter/AlignedChunkData.java | 36 ++++++------------- .../BatchedAlignedValueChunkData.java | 5 --- .../load/splitter/TsFileSplitter.java | 22 +++++------- 3 files changed, 19 insertions(+), 44 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index 606d1cccd414..8647be19d396 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -55,7 +55,6 @@ import java.nio.BufferOverflowException; import java.nio.ByteBuffer; import java.util.ArrayList; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; @@ -159,16 +158,6 @@ public AlignedChunkData( addAttrDataSize(); } - protected AlignedChunkData(final AlignedChunkData alignedChunkData) { - this( - alignedChunkData.device, - alignedChunkData.timePartitionSlot, - alignedChunkData.encryptParameter); - this.satisfiedLengthQueue = new LinkedList<>(alignedChunkData.satisfiedLengthQueue); - this.needDecodeChunk = alignedChunkData.needDecodeChunk; - addAttrDataSize(); - } - protected AlignedChunkData( @Nonnull final IDeviceID device, final TTimePartitionSlot timePartitionSlot) { this(device, timePartitionSlot, EncryptUtils.getEncryptParameter()); @@ -560,7 +549,6 @@ private void writePageBuffersToWriter( // Cache partition bounds: resolving them per point performs BigInteger work and allocations. final long partitionStart = timePartitionSlot.getStartTime(); final long partitionEnd = TimePartitionUtils.getTimePartitionEndTime(partitionStart); - final Set chunkIndexesWithData = chunkIndexesWithData(directlyBuiltChunkIndexes); for (final PageBuffer page : pageBuffers) { if (directlyBuiltChunkIndexes.contains(page.chunkIndex)) { continue; @@ -571,7 +559,7 @@ private void writePageBuffersToWriter( // of a value chunk with the pages of its time chunk by position, so dropping the page would // shift every later page of the value chunk onto the wrong timestamps. A chunk that has no // page with data at all keeps the zero-page layout of an all-null column instead. - if (page.pageData.remaining() == 0 && !chunkIndexesWithData.contains(page.chunkIndex)) { + if (page.pageData.remaining() == 0 && !hasPageWithData(page.chunkIndex)) { continue; } if (page.chunkIndex == 0) { @@ -608,23 +596,19 @@ private void writePageBuffersToWriter( } /** - * Chunk indexes written here that carry at least one point. Their empty pages are written too so - * that the page sequence of every value chunk stays aligned with the page sequence of the time - * chunk; a chunk without any point keeps the zero-page layout of an all-null column. + * Whether the chunk written here carries at least one point. The empty pages of such a chunk are + * written as well, so that the page sequence of every value chunk stays aligned with the page + * sequence of the time chunk; a chunk without any point keeps the zero-page layout of an all-null + * column. Only empty pages ask this question, so the scan stays off the common path. */ - private Set chunkIndexesWithData(final Set directlyBuiltChunkIndexes) { - final Set chunkIndexesWithData = new HashSet<>(); + private boolean hasPageWithData(final int chunkIndex) { for (final PageBuffer page : pageBuffers) { - if (directlyBuiltChunkIndexes.contains(page.chunkIndex)) { - continue; - } - final boolean hasData = - page.needDecode ? page.satisfiedLength > 0 : page.pageData.remaining() > 0; - if (hasData) { - chunkIndexesWithData.add(page.chunkIndex); + if (page.chunkIndex == chunkIndex + && (page.needDecode ? page.satisfiedLength > 0 : page.pageData.remaining() > 0)) { + return true; } } - return chunkIndexesWithData; + return false; } private static int lowerBound(final long[] times, final int low, final int high, final long key) { diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java index 56bff063d705..3bf2f3da5237 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java @@ -53,11 +53,6 @@ public class BatchedAlignedValueChunkData extends AlignedChunkData { private static final ByteBuffer EMPTY_CHUNK_DATA = ByteBuffer.allocate(0); - // Used for splitter - public BatchedAlignedValueChunkData(AlignedChunkData alignedChunkData) { - super(alignedChunkData); - } - // Used for deserialize public BatchedAlignedValueChunkData(IDeviceID device, TTimePartitionSlot timePartitionSlot) { super(device, timePartitionSlot); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java index edb23a148cb0..719d9e2b0ea5 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java @@ -61,6 +61,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -472,20 +473,15 @@ private void consumeAllAlignedChunkData( return; } - // Insertion-ordered on purpose: the chunks of a series must keep the page order of the - // source TsFile, so that the emitted pieces (and the replicas applying them) write the time - // partitions in ascending order. - Map chunkDataMap = new LinkedHashMap<>(); - for (Map.Entry> entry : pageIndex2ChunkData.entrySet()) { - List alignedChunkDataList = entry.getValue(); - for (int i = 0; i < alignedChunkDataList.size(); i++) { - AlignedChunkData oldChunkData = alignedChunkDataList.get(i); - BatchedAlignedValueChunkData chunkData = - chunkDataMap.computeIfAbsent(oldChunkData, BatchedAlignedValueChunkData::new); - alignedChunkDataList.set(i, chunkData); - } + // Insertion-ordered and de-duplicating on purpose: the chunks of a series must keep the page + // order of the source TsFile, so that the emitted pieces (and the replicas applying them) write + // the time partitions in ascending order, and a chunk filled by more than one page index is + // only written once. + final Set chunkDataSet = new LinkedHashSet<>(); + for (final Map.Entry> entry : pageIndex2ChunkData.entrySet()) { + chunkDataSet.addAll(entry.getValue()); } - for (AlignedChunkData chunkData : chunkDataMap.keySet()) { + for (final AlignedChunkData chunkData : chunkDataSet) { timePartitionSlots.add(chunkData.getTimePartitionSlot()); if (deletions.isEmpty() && timePartitionSlots.size() > CONFIG.getLoadTsFileSpiltPartitionMaxSize()) { From 746e76ae2c1cc4c134553e3b292e2cbc17b6b656 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Thu, 22 Oct 2026 11:32:10 +0800 Subject: [PATCH 17/18] Keep a drained chunk data out of the splitter's map The splitter drains pageIndex2ChunkData to the consumer and keeps using the same map for the pages that follow. The last cleanup dropped the wrapper that detached a drained object from that map, so a chunk data that had been handed to the consumer stayed in place: every later flush drained it again and re-emitted the chunks it had already emitted. A file split at four flush points therefore ended up with four time chunks while s2 and s3 had only two chunks each. The reader pairs the two lists by index (AbstractAlignedTimeSeriesMetadata#getAlignedChunkMetadata), so it threw IndexOutOfBoundsException: Index 2 out of bounds for length 2, which is what BatchedCompactionWithTsFileSplitterTest reported. Store a fresh BatchedAlignedValueChunkData back into every drained entry so the pages that follow land in a new object, and keep the hasPageWithData scan that replaced the per-chunk index set. --- .../load/splitter/AlignedChunkData.java | 17 +++++++++++++++++ .../splitter/BatchedAlignedValueChunkData.java | 5 +++++ .../load/splitter/TsFileSplitter.java | 17 +++++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java index 8647be19d396..7354a9d21b07 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/AlignedChunkData.java @@ -158,6 +158,23 @@ public AlignedChunkData( addAttrDataSize(); } + /** + * Detaches a consumed chunk data from the splitter: the copy is stored back into the map the + * splitter keeps draining, so the pages that follow land in a fresh object instead of the object + * that was just handed to the consumer. Without the copy the same object would be consumed again + * with every later flush and would re-emit the chunks it already emitted, which leaves the split + * TsFile with more time chunks than each value column has chunks. + */ + protected AlignedChunkData(final AlignedChunkData alignedChunkData) { + this( + alignedChunkData.device, + alignedChunkData.timePartitionSlot, + alignedChunkData.encryptParameter); + this.satisfiedLengthQueue = new LinkedList<>(alignedChunkData.satisfiedLengthQueue); + this.needDecodeChunk = alignedChunkData.needDecodeChunk; + addAttrDataSize(); + } + protected AlignedChunkData( @Nonnull final IDeviceID device, final TTimePartitionSlot timePartitionSlot) { this(device, timePartitionSlot, EncryptUtils.getEncryptParameter()); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java index 3bf2f3da5237..56bff063d705 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/BatchedAlignedValueChunkData.java @@ -53,6 +53,11 @@ public class BatchedAlignedValueChunkData extends AlignedChunkData { private static final ByteBuffer EMPTY_CHUNK_DATA = ByteBuffer.allocate(0); + // Used for splitter + public BatchedAlignedValueChunkData(AlignedChunkData alignedChunkData) { + super(alignedChunkData); + } + // Used for deserialize public BatchedAlignedValueChunkData(IDeviceID device, TTimePartitionSlot timePartitionSlot) { super(device, timePartitionSlot); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java index 719d9e2b0ea5..7a976838d705 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java @@ -61,7 +61,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -477,11 +476,21 @@ private void consumeAllAlignedChunkData( // order of the source TsFile, so that the emitted pieces (and the replicas applying them) write // the time partitions in ascending order, and a chunk filled by more than one page index is // only written once. - final Set chunkDataSet = new LinkedHashSet<>(); + // + // Every entry is swapped for a fresh wrapper while the object that holds the pages is the one + // handed to the consumer: a consumed chunk data must not be drained again by a later flush, so + // the map keeps objects that start empty instead of the ones that were just emitted. + final Map chunkDataMap = new LinkedHashMap<>(); for (final Map.Entry> entry : pageIndex2ChunkData.entrySet()) { - chunkDataSet.addAll(entry.getValue()); + final List alignedChunkDataList = entry.getValue(); + for (int i = 0; i < alignedChunkDataList.size(); i++) { + final AlignedChunkData oldChunkData = alignedChunkDataList.get(i); + final BatchedAlignedValueChunkData chunkData = + chunkDataMap.computeIfAbsent(oldChunkData, BatchedAlignedValueChunkData::new); + alignedChunkDataList.set(i, chunkData); + } } - for (final AlignedChunkData chunkData : chunkDataSet) { + for (final AlignedChunkData chunkData : chunkDataMap.keySet()) { timePartitionSlots.add(chunkData.getTimePartitionSlot()); if (deletions.isEmpty() && timePartitionSlots.size() > CONFIG.getLoadTsFileSpiltPartitionMaxSize()) { From 298bbc1d90100a9783b234639f0af6cd37ca28de Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Thu, 22 Oct 2026 11:56:03 +0800 Subject: [PATCH 18/18] Cover the batched aligned layout in TsFileSplitterTest The batched layout that compaction writes when compactionMaxAlignedSeriesNumInOneBatch is smaller than the series count keeps one time chunk per time window and appends the value chunks of the remaining series batches behind all time chunks, so the splitter flushes the chunk data of a time chunk several times. None of the splitter tests built such a file, which is why the duplicated chunks of the last fix went unnoticed. Add three cases that write the batched layout by hand: - two time windows in two time partitions, all series in three batches, which must be split into one chunk group per partition without duplicated chunks - the same layout with an empty value chunk and an all-null value page in a window that crosses a time partition boundary, so the copied time chunk and the decoded one are both flushed more than once - the same layout for two devices of one table The cases check the emitted chunk data, and the split TsFiles are read back to verify the chunk metadata and the values of every series. --- .../load/TsFileSplitterTest.java | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java index f08e0ce72b4d..d70cb40e8141 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java @@ -32,6 +32,7 @@ import com.sun.management.ThreadMXBean; import org.apache.tsfile.common.conf.TSFileConfig; import org.apache.tsfile.common.conf.TSFileDescriptor; +import org.apache.tsfile.encoding.encoder.PlainEncoder; import org.apache.tsfile.enums.ColumnCategory; import org.apache.tsfile.enums.TSDataType; import org.apache.tsfile.file.MetaMarker; @@ -45,9 +46,13 @@ import org.apache.tsfile.file.metadata.StringArrayDeviceID; import org.apache.tsfile.file.metadata.TableSchema; import org.apache.tsfile.file.metadata.TimeseriesMetadata; +import org.apache.tsfile.file.metadata.enums.CompressionType; import org.apache.tsfile.file.metadata.enums.MetadataIndexNodeType; +import org.apache.tsfile.file.metadata.enums.TSEncoding; +import org.apache.tsfile.file.metadata.statistics.Statistics; import org.apache.tsfile.read.TsFileReader; import org.apache.tsfile.read.TsFileSequenceReader; +import org.apache.tsfile.read.common.Chunk; import org.apache.tsfile.read.common.Field; import org.apache.tsfile.read.common.Path; import org.apache.tsfile.read.common.RowRecord; @@ -56,6 +61,7 @@ import org.apache.tsfile.utils.ReadWriteIOUtils; import org.apache.tsfile.write.chunk.AlignedChunkWriterImpl; import org.apache.tsfile.write.chunk.ChunkWriterImpl; +import org.apache.tsfile.write.chunk.ValueChunkWriter; import org.apache.tsfile.write.schema.IMeasurementSchema; import org.apache.tsfile.write.schema.MeasurementSchema; import org.apache.tsfile.write.schema.Schema; @@ -75,8 +81,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; public class TsFileSplitterTest { @@ -634,6 +644,506 @@ public void testSplitTableTimeOnlyAlignedChunkAtExactLongMaxUpperBound() throws } } + private static final List BATCHED_MEASUREMENTS = + Arrays.asList("s1", "s2", "s3", "s4", "s5"); + + private static final List> BATCHED_MEASUREMENT_BATCHES = + Arrays.asList( + Arrays.asList("s1", "s2"), Arrays.asList("s3", "s4"), Collections.singletonList("s5")); + + /** + * A batched aligned TsFile holds one time chunk per time window, but the value chunks of the + * remaining series batches are appended behind all time chunks. That is what the compaction + * writer emits when {@code compactionMaxAlignedSeriesNumInOneBatch} is smaller than the series + * count, and it makes the splitter flush the chunk data of a time chunk several times. + */ + @Test + public void testSplitBatchedAlignedChunkAcrossTimePartitions() throws Exception { + final File sourceTsFile = new File("split-batched-aligned-source.tsfile"); + final IDeviceID deviceID = new StringArrayDeviceID("table1", "tagA"); + final int previousMaxNumberOfPointsInPage = + TSFileDescriptor.getInstance().getConfig().getMaxNumberOfPointsInPage(); + final long firstPartitionStartTime = + TimePartitionUtils.getTimePartitionSlot(1_700_000_000_000L).getStartTime(); + final long timePartitionInterval = TimePartitionUtils.getTimePartitionInterval(); + final List> timeWindows = + Arrays.asList( + Arrays.asList( + firstPartitionStartTime, firstPartitionStartTime + 1, firstPartitionStartTime + 2), + Arrays.asList( + firstPartitionStartTime + timePartitionInterval, + firstPartitionStartTime + timePartitionInterval + 1)); + final File[] targetTsFiles = { + new File("split-batched-aligned-target-0.tsfile"), + new File("split-batched-aligned-target-1.tsfile") + }; + try { + TSFileDescriptor.getInstance().getConfig().setMaxNumberOfPointsInPage(2); + writeBatchedAlignedTsFile( + sourceTsFile, + Collections.singletonList(deviceID), + timeWindows, + BATCHED_MEASUREMENT_BATCHES, + Collections.emptyMap(), + Collections.emptyMap()); + + final List emittedChunkDataList = splitSourceTsFile(sourceTsFile); + // The splitter flushes the chunk data at every series batch, so it is emitted several times. + Assert.assertTrue(emittedChunkDataList.size() > 2); + for (final ChunkData chunkData : emittedChunkDataList) { + assertNoDuplicatedChunk(chunkData); + } + + final Map> chunkDataByPartition = + groupChunkDataByPartition(emittedChunkDataList); + Assert.assertEquals(2, chunkDataByPartition.size()); + + final List firstPartitionChunkDataList = + chunkDataByPartition.get(new TTimePartitionSlot(firstPartitionStartTime)); + Assert.assertNotNull(firstPartitionChunkDataList); + Assert.assertTrue(firstPartitionChunkDataList.size() > 1); + assertBatchedAlignedTarget( + writeTargetTsFile(targetTsFiles[0], firstPartitionChunkDataList), + deviceID, + new long[] { + firstPartitionStartTime, firstPartitionStartTime + 1, firstPartitionStartTime + 2 + }, + 1, + Collections.emptySet()); + + final List secondPartitionChunkDataList = + chunkDataByPartition.get( + new TTimePartitionSlot(firstPartitionStartTime + timePartitionInterval)); + Assert.assertNotNull(secondPartitionChunkDataList); + Assert.assertTrue(secondPartitionChunkDataList.size() > 1); + assertBatchedAlignedTarget( + writeTargetTsFile(targetTsFiles[1], secondPartitionChunkDataList), + deviceID, + new long[] { + firstPartitionStartTime + timePartitionInterval, + firstPartitionStartTime + timePartitionInterval + 1 + }, + 1, + Collections.emptySet()); + } finally { + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(previousMaxNumberOfPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + for (final File targetTsFile : targetTsFiles) { + if (targetTsFile.exists()) { + Assert.assertTrue(targetTsFile.delete()); + } + } + } + } + + /** + * The same batched layout mixed with the remaining chunk shapes: the first time window stays + * inside one time partition and carries empty value chunks, while the second window crosses the + * partition boundary and carries the all-null (empty page) value chunks. + */ + @Test + public void testSplitBatchedAlignedChunkWithEmptyAndNullValueChunks() throws Exception { + final File sourceTsFile = new File("split-batched-aligned-special-source.tsfile"); + final IDeviceID deviceID = new StringArrayDeviceID("table1", "tagA"); + final int previousMaxNumberOfPointsInPage = + TSFileDescriptor.getInstance().getConfig().getMaxNumberOfPointsInPage(); + final long firstPartitionStartTime = + TimePartitionUtils.getTimePartitionSlot(1_700_100_000_000L).getStartTime(); + final long timePartitionInterval = TimePartitionUtils.getTimePartitionInterval(); + final long crossedTime = firstPartitionStartTime + timePartitionInterval - 1; + final long nextPartitionStartTime = firstPartitionStartTime + timePartitionInterval; + final List> timeWindows = + Arrays.asList( + Collections.singletonList(firstPartitionStartTime + 3), + Arrays.asList(crossedTime, nextPartitionStartTime)); + final Map> emptyMeasurementIdsByWindow = + Collections.singletonMap(0, new HashSet<>(Arrays.asList("s4", "s5"))); + final Map> nullMeasurementIdsByWindow = + Collections.singletonMap(1, new HashSet<>(Arrays.asList("s4", "s5"))); + final Set nullMeasurements = new HashSet<>(Arrays.asList("s4", "s5")); + final File[] targetTsFiles = { + new File("split-batched-aligned-special-target-0.tsfile"), + new File("split-batched-aligned-special-target-1.tsfile") + }; + try { + TSFileDescriptor.getInstance().getConfig().setMaxNumberOfPointsInPage(2); + writeBatchedAlignedTsFile( + sourceTsFile, + Collections.singletonList(deviceID), + timeWindows, + BATCHED_MEASUREMENT_BATCHES, + emptyMeasurementIdsByWindow, + nullMeasurementIdsByWindow); + + final List emittedChunkDataList = splitSourceTsFile(sourceTsFile); + Assert.assertTrue(emittedChunkDataList.size() > 2); + for (final ChunkData chunkData : emittedChunkDataList) { + assertNoDuplicatedChunk(chunkData); + } + + final Map> chunkDataByPartition = + groupChunkDataByPartition(emittedChunkDataList); + Assert.assertEquals(2, chunkDataByPartition.size()); + + final List firstPartitionChunkDataList = + chunkDataByPartition.get(new TTimePartitionSlot(firstPartitionStartTime)); + Assert.assertNotNull(firstPartitionChunkDataList); + assertBatchedAlignedTarget( + writeTargetTsFile(targetTsFiles[0], firstPartitionChunkDataList), + deviceID, + new long[] {firstPartitionStartTime + 3, crossedTime}, + 2, + nullMeasurements); + + final List secondPartitionChunkDataList = + chunkDataByPartition.get(new TTimePartitionSlot(nextPartitionStartTime)); + Assert.assertNotNull(secondPartitionChunkDataList); + assertBatchedAlignedTarget( + writeTargetTsFile(targetTsFiles[1], secondPartitionChunkDataList), + deviceID, + new long[] {nextPartitionStartTime}, + 1, + nullMeasurements); + } finally { + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(previousMaxNumberOfPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + for (final File targetTsFile : targetTsFiles) { + if (targetTsFile.exists()) { + Assert.assertTrue(targetTsFile.delete()); + } + } + } + } + + /** Every device of a batched TsFile keeps its own flush state. */ + @Test + public void testSplitBatchedAlignedChunkOfMultipleDevices() throws Exception { + final File sourceTsFile = new File("split-batched-aligned-multi-device-source.tsfile"); + final List deviceIDs = + Arrays.asList( + new StringArrayDeviceID("table1", "tagA"), new StringArrayDeviceID("table1", "tagB")); + final int previousMaxNumberOfPointsInPage = + TSFileDescriptor.getInstance().getConfig().getMaxNumberOfPointsInPage(); + final long firstPartitionStartTime = + TimePartitionUtils.getTimePartitionSlot(1_700_200_000_000L).getStartTime(); + final long timePartitionInterval = TimePartitionUtils.getTimePartitionInterval(); + final List> timeWindows = + Arrays.asList( + Arrays.asList(firstPartitionStartTime, firstPartitionStartTime + 1), + Arrays.asList( + firstPartitionStartTime + timePartitionInterval, + firstPartitionStartTime + timePartitionInterval + 1)); + final File[] targetTsFiles = { + new File("split-batched-aligned-multi-device-target-0.tsfile"), + new File("split-batched-aligned-multi-device-target-1.tsfile") + }; + try { + TSFileDescriptor.getInstance().getConfig().setMaxNumberOfPointsInPage(2); + writeBatchedAlignedTsFile( + sourceTsFile, + deviceIDs, + timeWindows, + BATCHED_MEASUREMENT_BATCHES, + Collections.emptyMap(), + Collections.emptyMap()); + + final List emittedChunkDataList = splitSourceTsFile(sourceTsFile); + for (final ChunkData chunkData : emittedChunkDataList) { + assertNoDuplicatedChunk(chunkData); + } + + final Map> chunkDataByPartition = + groupChunkDataByPartition(emittedChunkDataList); + Assert.assertEquals(2, chunkDataByPartition.size()); + + final List firstPartitionChunkDataList = + chunkDataByPartition.get(new TTimePartitionSlot(firstPartitionStartTime)); + Assert.assertNotNull(firstPartitionChunkDataList); + final File firstTargetTsFile = + writeTargetTsFile(targetTsFiles[0], firstPartitionChunkDataList); + final List secondPartitionChunkDataList = + chunkDataByPartition.get( + new TTimePartitionSlot(firstPartitionStartTime + timePartitionInterval)); + Assert.assertNotNull(secondPartitionChunkDataList); + final File secondTargetTsFile = + writeTargetTsFile(targetTsFiles[1], secondPartitionChunkDataList); + + for (final IDeviceID deviceID : deviceIDs) { + assertBatchedAlignedTarget( + firstTargetTsFile, + deviceID, + new long[] {firstPartitionStartTime, firstPartitionStartTime + 1}, + 1, + Collections.emptySet()); + assertBatchedAlignedTarget( + secondTargetTsFile, + deviceID, + new long[] { + firstPartitionStartTime + timePartitionInterval, + firstPartitionStartTime + timePartitionInterval + 1 + }, + 1, + Collections.emptySet()); + } + } finally { + TSFileDescriptor.getInstance() + .getConfig() + .setMaxNumberOfPointsInPage(previousMaxNumberOfPointsInPage); + if (sourceTsFile.exists()) { + Assert.assertTrue(sourceTsFile.delete()); + } + for (final File targetTsFile : targetTsFiles) { + if (targetTsFile.exists()) { + Assert.assertTrue(targetTsFile.delete()); + } + } + } + } + + private void writeBatchedAlignedTsFile( + final File tsFile, + final List deviceIDs, + final List> timeWindows, + final List> measurementBatches, + final Map> emptyMeasurementIdsByWindow, + final Map> nullMeasurementIdsByWindow) + throws Exception { + if (tsFile.exists()) { + Assert.assertTrue(tsFile.delete()); + } + try (final TsFileIOWriter writer = new TsFileIOWriter(tsFile)) { + writer.setSchema(createBatchedSchema()); + for (final IDeviceID deviceID : deviceIDs) { + writer.startChunkGroup(deviceID); + for (int batchIndex = 0; batchIndex < measurementBatches.size(); batchIndex++) { + final List measurementBatch = measurementBatches.get(batchIndex); + for (int windowIndex = 0; windowIndex < timeWindows.size(); windowIndex++) { + final List timeWindow = timeWindows.get(windowIndex); + final Set emptyMeasurements = + emptyMeasurementIdsByWindow.getOrDefault(windowIndex, Collections.emptySet()); + final Set nullMeasurements = + nullMeasurementIdsByWindow.getOrDefault(windowIndex, Collections.emptySet()); + if (batchIndex == 0) { + // The first batch carries the time chunk of the window, the following batches only + // append their value chunks behind it, exactly like the batched compaction writer. + final List measurementSchemas = new ArrayList<>(); + for (final String measurement : measurementBatch) { + if (!emptyMeasurements.contains(measurement)) { + measurementSchemas.add(new MeasurementSchema(measurement, TSDataType.INT64)); + } + } + final AlignedChunkWriterImpl alignedChunkWriter = + new AlignedChunkWriterImpl(measurementSchemas); + for (final long time : timeWindow) { + alignedChunkWriter.getTimeChunkWriter().write(time); + } + int valueIndex = 0; + for (final String measurement : measurementBatch) { + if (emptyMeasurements.contains(measurement)) { + continue; + } + final ValueChunkWriter valueChunkWriter = + alignedChunkWriter.getValueChunkWriterByIndex(valueIndex++); + for (final long time : timeWindow) { + valueChunkWriter.write( + time, + batchedValue(measurement, time), + nullMeasurements.contains(measurement)); + } + } + alignedChunkWriter.writeToFileWriter(writer); + } else { + for (final String measurement : measurementBatch) { + if (emptyMeasurements.contains(measurement)) { + continue; + } + final ValueChunkWriter valueChunkWriter = + new ValueChunkWriter( + measurement, + CompressionType.LZ4, + TSDataType.INT64, + TSEncoding.PLAIN, + new PlainEncoder(TSDataType.INT64, 8)); + for (final long time : timeWindow) { + valueChunkWriter.write( + time, + batchedValue(measurement, time), + nullMeasurements.contains(measurement)); + } + valueChunkWriter.writeToFileWriter(writer); + } + } + for (final String measurement : measurementBatch) { + if (emptyMeasurements.contains(measurement)) { + writer.writeEmptyValueChunk( + measurement, + CompressionType.LZ4, + TSDataType.INT64, + TSEncoding.PLAIN, + Statistics.getStatsByType(TSDataType.INT64)); + } + } + } + } + writer.endChunkGroup(); + } + writer.endFile(); + } + } + + private static long batchedValue(final String measurement, final long time) { + return (BATCHED_MEASUREMENTS.indexOf(measurement) + 1) * 1_000_000_000L + time; + } + + private List splitSourceTsFile(final File sourceTsFile) throws Exception { + final List emittedChunkDataList = new ArrayList<>(); + final TsFileSplitter splitter = + new TsFileSplitter( + sourceTsFile, + tsFileData -> { + if (tsFileData instanceof ChunkData) { + emittedChunkDataList.add((ChunkData) tsFileData); + } + return true; + }); + splitter.splitTsFileByDataPartition(); + return emittedChunkDataList; + } + + private Map> groupChunkDataByPartition( + final List emittedChunkDataList) { + final Map> chunkDataByPartition = new LinkedHashMap<>(); + for (final ChunkData chunkData : emittedChunkDataList) { + chunkDataByPartition + .computeIfAbsent(chunkData.getTimePartitionSlot(), slot -> new ArrayList<>()) + .add(chunkData); + } + return chunkDataByPartition; + } + + private void assertNoDuplicatedChunk(final ChunkData chunkData) { + final Set measurementIds = new HashSet<>(); + for (final Chunk chunk : chunkData.getChunks()) { + final String measurementId = chunk.getHeader().getMeasurementID(); + Assert.assertTrue( + "Duplicated chunk of measurement " + + measurementId + + " in one emitted ChunkData, the chunk data was flushed without being reset", + measurementIds.add(measurementId.isEmpty() ? "