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

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


The following commit(s) were added to refs/heads/master by this push:
     new 27e9920399b feat: cache sorted WAL file lists (#18456)
27e9920399b is described below

commit 27e9920399b3200de2117b651aed235c12c9ee2f
Author: Jiang Tian <[email protected]>
AuthorDate: Thu Aug 13 15:02:26 2026 +0800

    feat: cache sorted WAL file lists (#18456)
    
    * ver1
    
    * ver2
    
    * add perftest
    
    * feat: enable WAL file list cache by default
---
 .../java/org/apache/iotdb/db/conf/IoTDBConfig.java |  11 +
 .../org/apache/iotdb/db/conf/IoTDBDescriptor.java  |   6 +
 .../dataregion/wal/buffer/WALBuffer.java           |  34 ++-
 .../storageengine/dataregion/wal/node/WALNode.java | 108 ++++++-
 .../org/apache/iotdb/db/conf/PropertiesTest.java   |  28 ++
 .../wal/node/ConsensusReqReaderTest.java           |  51 ++++
 .../wal/node/WALFileListCachePerformanceTest.java  | 338 +++++++++++++++++++++
 .../wal/node/WalDeleteOutdatedNewTest.java         |  12 +
 .../conf/iotdb-system.properties.template          |   9 +
 9 files changed, 585 insertions(+), 12 deletions(-)

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 1d726366ddd..5e0ff2dec6a 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
@@ -219,6 +219,9 @@ public class IoTDBConfig {
   /** The period when outdated wal files are periodically deleted. Unit: 
millisecond */
   private volatile long deleteWalFilesPeriodInMs = 20 * 1000L;
 
+  /** Whether WAL nodes cache their sorted WAL file lists. */
+  private boolean walFileListCacheEnabled = true;
+
   /**
    * Enables or disables the automatic clearing of the WAL cache when a memory 
compaction is
    * triggered. When enabled, the WAL cache will be cleared to release memory 
during the compaction
@@ -2220,6 +2223,14 @@ public class IoTDBConfig {
     this.deleteWalFilesPeriodInMs = deleteWalFilesPeriodInMs;
   }
 
+  public boolean isWalFileListCacheEnabled() {
+    return walFileListCacheEnabled;
+  }
+
+  public void setWalFileListCacheEnabled(boolean walFileListCacheEnabled) {
+    this.walFileListCacheEnabled = walFileListCacheEnabled;
+  }
+
   public boolean getWALCacheShrinkClearEnabled() {
     return WALCacheShrinkClearEnabled;
   }
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 15d2b7d003d..fedcc73dbbe 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
@@ -1414,6 +1414,12 @@ public class IoTDBDescriptor {
       conf.setWALCacheShrinkClearEnabled(WALInsertNodeCacheShrinkClearEnabled);
     }
 
+    conf.setWalFileListCacheEnabled(
+        Boolean.parseBoolean(
+            properties.getProperty(
+                "wal_file_list_cache_enabled",
+                Boolean.toString(conf.isWalFileListCacheEnabled()))));
+
     loadWALHotModifiedProps(properties);
   }
 
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/buffer/WALBuffer.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/buffer/WALBuffer.java
index 25fb31ba804..6921abecf75 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/buffer/WALBuffer.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/buffer/WALBuffer.java
@@ -64,6 +64,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.BiConsumer;
 import java.util.function.Predicate;
 
 import static 
org.apache.iotdb.db.storageengine.dataregion.wal.node.WALNode.DEFAULT_SEARCH_INDEX;
@@ -122,9 +123,16 @@ public class WALBuffer extends AbstractWALBuffer {
 
   // manage wal files which have MemTableIds
   private final Map<Long, Set<Long>> memTableIdsOfWal = new 
ConcurrentHashMap<>();
+  private final BiConsumer<File, File> walFileRolledListener;
 
   public WALBuffer(String identifier, String logDirectory) throws IOException {
-    this(identifier, logDirectory, new CheckpointManager(identifier, 
logDirectory), 0, 0L);
+    this(
+        identifier,
+        logDirectory,
+        new CheckpointManager(identifier, logDirectory),
+        0,
+        0L,
+        (sealedWalFile, currentWalFile) -> {});
   }
 
   public WALBuffer(
@@ -134,8 +142,26 @@ public class WALBuffer extends AbstractWALBuffer {
       long startFileVersion,
       long startSearchIndex)
       throws IOException {
+    this(
+        identifier,
+        logDirectory,
+        checkpointManager,
+        startFileVersion,
+        startSearchIndex,
+        (sealedWalFile, currentWalFile) -> {});
+  }
+
+  public WALBuffer(
+      String identifier,
+      String logDirectory,
+      CheckpointManager checkpointManager,
+      long startFileVersion,
+      long startSearchIndex,
+      BiConsumer<File, File> walFileRolledListener)
+      throws IOException {
     super(identifier, logDirectory, startFileVersion, startSearchIndex);
     this.checkpointManager = checkpointManager;
+    this.walFileRolledListener = walFileRolledListener;
     currentFileStatus = WALFileStatus.CONTAINS_NONE_SEARCH_INDEX;
     allocateBuffers();
     currentWALFileWriter.setCompressedByteBuffer(compressedByteBuffer);
@@ -168,8 +194,10 @@ public class WALBuffer extends AbstractWALBuffer {
 
   @Override
   protected File rollLogWriter(long searchIndex, WALFileStatus fileStatus) 
throws IOException {
-    File file = super.rollLogWriter(searchIndex, fileStatus);
+    File sealedWalFile = super.rollLogWriter(searchIndex, fileStatus);
     currentWALFileWriter.setCompressedByteBuffer(compressedByteBuffer);
+    // Update the WAL node's ordered file index before waking readers waiting 
for this roll.
+    walFileRolledListener.accept(sealedWalFile, 
currentWALFileWriter.getLogFile());
     buffersLock.lock();
     try {
       // notify WALReader that new file is generated, and it can read new file
@@ -177,7 +205,7 @@ public class WALBuffer extends AbstractWALBuffer {
     } finally {
       buffersLock.unlock();
     }
-    return file;
+    return sealedWalFile;
   }
 
   @TestOnly
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALNode.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALNode.java
index a59ebeaa21d..03438b60707 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALNode.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALNode.java
@@ -82,6 +82,7 @@ import java.util.ListIterator;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -120,6 +121,15 @@ public class WALNode implements IWALNode {
   // WAL files with versionId >= this value are retained for subscription 
consumers
   private volatile long subscriptionRetainedMinVersionId = Long.MAX_VALUE;
 
+  private final boolean walFileListCacheEnabled;
+  private final Object walFileListCacheLock = new Object();
+  // Maintains the cache incrementally by version; all mutations are protected 
by the cache lock.
+  private final TreeMap<Long, File> walFilesByVersion = new TreeMap<>();
+  // File changes only mark the cache dirty; the next file-list access 
publishes a snapshot lazily.
+  private volatile boolean sortedWalFilesCacheDirty = true;
+  // Replaced as a never-mutated snapshot so readers cannot observe a 
partially updated list.
+  private volatile File[] sortedWalFilesCache;
+
   private volatile boolean deleted = false;
 
   public WALNode(String identifier, String logDirectory) throws IOException {
@@ -135,9 +145,80 @@ public class WALNode implements IWALNode {
       logger.info(StorageEngineMessages.CREATE_FOLDER_FOR_WAL_NODE, 
logDirectory, identifier);
     }
     this.checkpointManager = new CheckpointManager(identifier, logDirectory);
+    this.walFileListCacheEnabled = config.isWalFileListCacheEnabled();
     this.buffer =
         new WALBuffer(
-            identifier, logDirectory, checkpointManager, startFileVersion, 
startSearchIndex);
+            identifier,
+            logDirectory,
+            checkpointManager,
+            startFileVersion,
+            startSearchIndex,
+            this::updateSortedWalFilesCacheAfterRoll);
+    initializeSortedWalFilesCacheIfEnabled();
+  }
+
+  private File[] getSortedWalFiles() {
+    if (!walFileListCacheEnabled) {
+      return listAndSortWalFiles();
+    }
+    File[] snapshot = sortedWalFilesCache;
+    if (!sortedWalFilesCacheDirty && snapshot != null) {
+      return snapshot;
+    }
+    synchronized (walFileListCacheLock) {
+      if (sortedWalFilesCacheDirty || sortedWalFilesCache == null) {
+        sortedWalFilesCache = walFilesByVersion.values().toArray(new File[0]);
+        sortedWalFilesCacheDirty = false;
+      }
+      return sortedWalFilesCache;
+    }
+  }
+
+  private File[] listAndSortWalFiles() {
+    final File[] walFiles = WALFileUtils.listAllWALFiles(logDirectory);
+    if (walFiles != null) {
+      WALFileUtils.ascSortByVersionId(walFiles);
+    }
+    return walFiles;
+  }
+
+  private void initializeSortedWalFilesCacheIfEnabled() {
+    if (walFileListCacheEnabled) {
+      synchronized (walFileListCacheLock) {
+        File[] walFiles = WALFileUtils.listAllWALFiles(logDirectory);
+        walFilesByVersion.clear();
+        if (walFiles != null) {
+          for (File walFile : walFiles) {
+            
walFilesByVersion.put(WALFileUtils.parseVersionId(walFile.getName()), walFile);
+          }
+        }
+        sortedWalFilesCacheDirty = true;
+      }
+    }
+  }
+
+  private void updateSortedWalFilesCacheAfterRoll(File sealedWalFile, File 
currentWalFile) {
+    if (!walFileListCacheEnabled) {
+      return;
+    }
+    synchronized (walFileListCacheLock) {
+      
walFilesByVersion.put(WALFileUtils.parseVersionId(sealedWalFile.getName()), 
sealedWalFile);
+      
walFilesByVersion.put(WALFileUtils.parseVersionId(currentWalFile.getName()), 
currentWalFile);
+      sortedWalFilesCacheDirty = true;
+    }
+  }
+
+  private void removeDeletedWalFilesFromCache(List<Long> deletedVersionIds) {
+    if (!walFileListCacheEnabled || deletedVersionIds.isEmpty()) {
+      return;
+    }
+    synchronized (walFileListCacheLock) {
+      for (Long deletedVersionId : deletedVersionIds) {
+        if (walFilesByVersion.remove(deletedVersionId) != null) {
+          sortedWalFilesCacheDirty = true;
+        }
+      }
+    }
   }
 
   @Override
@@ -291,7 +372,7 @@ public class WALNode implements IWALNode {
 
     private boolean initAndCheckIfNeedContinue() {
       rollWalFileIfHaveNoActiveMemTable();
-      File[] allWalFilesOfOneNode = WALFileUtils.listAllWALFiles(logDirectory);
+      File[] allWalFilesOfOneNode = getSortedWalFiles();
       if (allWalFilesOfOneNode == null || allWalFilesOfOneNode.length <= 1) {
         if (logger.isDebugEnabled()) {
           logger.debug(
@@ -301,7 +382,6 @@ public class WALNode implements IWALNode {
         }
         return false;
       }
-      WALFileUtils.ascSortByVersionId(allWalFilesOfOneNode);
       this.sortedWalFilesExcludingLast =
           Arrays.copyOfRange(allWalFilesOfOneNode, 0, 
allWalFilesOfOneNode.length - 1);
       this.activeOrPinnedMemTables = 
checkpointManager.activeOrPinnedMemTables();
@@ -413,6 +493,7 @@ public class WALNode implements IWALNode {
       }
       buffer.subtractDiskUsage(deleteFileSize);
       buffer.subtractFileNum(successfullyDeleted.size());
+      removeDeletedWalFilesFromCache(successfullyDeleted);
     }
 
     private int initFileIndexAfterFilterSafelyDeleteIndex() {
@@ -916,8 +997,7 @@ public class WALNode implements IWALNode {
     }
 
     private void updateFilesToSearch() {
-      File[] filesToSearch = WALFileUtils.listAllWALFiles(logDirectory);
-      WALFileUtils.ascSortByVersionId(filesToSearch);
+      File[] filesToSearch = getSortedWalFiles();
       int fileIndex = 
WALFileUtils.binarySearchFileBySearchIndex(filesToSearch, nextSearchIndex);
       logger.debug(
           StorageEngineMessages.STORAGE_LOG_SEARCHINDEX_RESULT_FILES_6151DCEB,
@@ -981,12 +1061,11 @@ public class WALNode implements IWALNode {
     if (bytesToFree <= 0) {
       return new Pair<>(DEFAULT_SAFELY_DELETED_SEARCH_INDEX, 0L);
     }
-    File[] walFiles = WALFileUtils.listAllWALFiles(logDirectory);
+    File[] walFiles = getSortedWalFiles();
     if (walFiles == null || walFiles.length <= 1) {
       // No files or only the current-writing file — cannot free anything
       return new Pair<>(DEFAULT_SAFELY_DELETED_SEARCH_INDEX, 0L);
     }
-    WALFileUtils.ascSortByVersionId(walFiles);
     // Exclude the last file (currently being written)
     long accumulated = 0;
     for (int i = 0; i < walFiles.length - 1; i++) {
@@ -1017,11 +1096,10 @@ public class WALNode implements IWALNode {
 
   @Override
   public Pair<Long, Long> getDeletionBoundBeforeTimestamp(long cutoffTimeMs) {
-    File[] walFiles = WALFileUtils.listAllWALFiles(logDirectory);
+    File[] walFiles = getSortedWalFiles();
     if (walFiles == null || walFiles.length <= 1) {
       return new Pair<>(Long.MIN_VALUE + 1, 0L);
     }
-    WALFileUtils.ascSortByVersionId(walFiles);
     int expiredPrefixLength = countExpiredRolledWalFiles(walFiles, 
cutoffTimeMs);
     if (expiredPrefixLength == 0) {
       return new Pair<>(Long.MIN_VALUE + 1, 0L);
@@ -1066,6 +1144,18 @@ public class WALNode implements IWALNode {
     return logDirectory;
   }
 
+  @TestOnly
+  File[] getCachedSortedWalFiles() {
+    return sortedWalFilesCache == null
+        ? null
+        : Arrays.copyOf(sortedWalFilesCache, sortedWalFilesCache.length);
+  }
+
+  @TestOnly
+  File[] getSortedWalFilesForTest() {
+    return getSortedWalFiles();
+  }
+
   /** Get the .wal file starts with the specified version id */
   public File getWALFile(long versionId) throws FileNotFoundException {
     return WALFileUtils.getWALFile(logDirectory, versionId);
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 191719ab8d4..80f8811d096 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
@@ -41,6 +41,34 @@ import java.util.Properties;
 import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
 
 public class PropertiesTest {
+  /**
+   * Verifies that WAL file-list caching defaults on, supports startup 
override, and is
+   * restart-only.
+   */
+  @Test
+  public void testWalFileListCacheConfiguration() throws Exception {
+    final IoTDBDescriptor descriptor = IoTDBDescriptor.getInstance();
+    final boolean originalValue = 
descriptor.getConfig().isWalFileListCacheEnabled();
+    final TrimProperties properties = new TrimProperties();
+
+    try {
+      Assert.assertTrue(new IoTDBConfig().isWalFileListCacheEnabled());
+      Assert.assertTrue(
+          Boolean.parseBoolean(
+              
ConfigurationFileUtils.getConfigurationDefaultValue("wal_file_list_cache_enabled")));
+
+      properties.setProperty("wal_file_list_cache_enabled", "false");
+      descriptor.loadProperties(properties);
+      Assert.assertFalse(descriptor.getConfig().isWalFileListCacheEnabled());
+
+      properties.setProperty("wal_file_list_cache_enabled", "true");
+      descriptor.loadHotModifiedProps(properties);
+      Assert.assertFalse(descriptor.getConfig().isWalFileListCacheEnabled());
+    } finally {
+      descriptor.getConfig().setWalFileListCacheEnabled(originalValue);
+    }
+  }
+
   @Test
   public void testHotReloadNegativeWalThrottleThresholdUsesDefault() throws 
Exception {
     final String key = "wal_throttle_threshold_in_byte";
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/ConsensusReqReaderTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/ConsensusReqReaderTest.java
index 761f9f16025..e1a48eb73f0 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/ConsensusReqReaderTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/ConsensusReqReaderTest.java
@@ -33,6 +33,8 @@ import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertRowNod
 import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertRowsNode;
 import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertTabletNode;
 import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALEntry;
+import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileStatus;
+import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileUtils;
 import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALMode;
 import org.apache.iotdb.db.utils.EnvironmentUtils;
 import org.apache.iotdb.db.utils.constant.TestConstant;
@@ -60,13 +62,16 @@ public class ConsensusReqReaderTest {
   private static final String logDirectory = 
TestConstant.BASE_OUTPUT_PATH.concat("wal-test");
   private static final String devicePath = "root.test_sg.test_d";
   private WALMode prevMode;
+  private boolean prevWalFileListCacheEnabled;
   private WALNode walNode;
 
   @Before
   public void setUp() throws Exception {
     EnvironmentUtils.cleanDir(logDirectory);
     prevMode = config.getWalMode();
+    prevWalFileListCacheEnabled = config.isWalFileListCacheEnabled();
     config.setWalMode(WALMode.SYNC);
+    config.setWalFileListCacheEnabled(true);
     walNode = new WALNode(identifier, logDirectory);
   }
 
@@ -74,9 +79,53 @@ public class ConsensusReqReaderTest {
   public void tearDown() throws Exception {
     walNode.close();
     config.setWalMode(prevMode);
+    config.setWalFileListCacheEnabled(prevWalFileListCacheEnabled);
     EnvironmentUtils.cleanDir(logDirectory);
   }
 
+  /** Verifies that reader access publishes an incrementally maintained file 
snapshot. */
+  @Test
+  public void testIncrementallyUpdateCachedWalFilesAfterRoll() throws 
IOException {
+    Assert.assertNull(walNode.getCachedSortedWalFiles());
+
+    Assert.assertFalse(walNode.getReqIterator(0).hasNext());
+    File[] initialCachedWalFiles = walNode.getCachedSortedWalFiles();
+    Assert.assertEquals(1, initialCachedWalFiles.length);
+    Assert.assertEquals(0, 
WALFileUtils.parseVersionId(initialCachedWalFiles[0].getName()));
+
+    // A directory rescan would incorrectly add this external sentinel to the 
cached snapshot.
+    Assert.assertTrue(new File(logDirectory, 
"_100-100-1.wal").createNewFile());
+    walNode.rollWALFile();
+    Assert.assertArrayEquals(initialCachedWalFiles, 
walNode.getCachedSortedWalFiles());
+
+    Assert.assertFalse(walNode.getReqIterator(0).hasNext());
+    File[] cachedWalFilesAfterFirstRoll = walNode.getCachedSortedWalFiles();
+    Assert.assertEquals(2, cachedWalFilesAfterFirstRoll.length);
+    Assert.assertEquals(
+        WALFileStatus.CONTAINS_NONE_SEARCH_INDEX,
+        
WALFileUtils.parseStatusCode(cachedWalFilesAfterFirstRoll[0].getName()));
+    Assert.assertEquals(1, 
WALFileUtils.parseVersionId(cachedWalFilesAfterFirstRoll[1].getName()));
+
+    walNode.rollWALFile();
+    Assert.assertArrayEquals(cachedWalFilesAfterFirstRoll, 
walNode.getCachedSortedWalFiles());
+
+    Assert.assertFalse(walNode.getReqIterator(0).hasNext());
+    File[] cachedWalFilesAfterSecondRoll = walNode.getCachedSortedWalFiles();
+    Assert.assertEquals(3, cachedWalFilesAfterSecondRoll.length);
+    for (int i = 0; i < cachedWalFilesAfterSecondRoll.length; i++) {
+      Assert.assertEquals(
+          i, 
WALFileUtils.parseVersionId(cachedWalFilesAfterSecondRoll[i].getName()));
+    }
+  }
+
+  private void recreateWalNodeWithoutFileListCache() throws IOException {
+    // These corruption tests replace WAL files outside WALNode, so use the 
directory-scanning mode
+    // that is designed to observe such external test-fixture mutations.
+    walNode.close();
+    config.setWalFileListCacheEnabled(false);
+    walNode = new WALNode(identifier, logDirectory);
+  }
+
   /**
    * Generate wal files as below: <br>
    * _0-0-1.wal: 1,-1 <br>
@@ -554,6 +603,7 @@ public class ConsensusReqReaderTest {
 
   @Test
   public void scenario03TestGetReqIterator01() throws Exception {
+    recreateWalNodeWithoutFileListCache();
     simulateFileScenario03();
     walNode.rollWALFile();
 
@@ -608,6 +658,7 @@ public class ConsensusReqReaderTest {
 
   @Test
   public void scenario03TestGetReqIterator02() throws Exception {
+    recreateWalNodeWithoutFileListCache();
     simulateFileScenario03();
     walNode.rollWALFile();
 
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALFileListCachePerformanceTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALFileListCachePerformanceTest.java
new file mode 100644
index 00000000000..340dfa8f300
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WALFileListCachePerformanceTest.java
@@ -0,0 +1,338 @@
+/*
+ * 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.dataregion.wal.node;
+
+import org.apache.iotdb.db.conf.IoTDBConfig;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileStatus;
+import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileUtils;
+import org.apache.iotdb.db.utils.EnvironmentUtils;
+import org.apache.iotdb.db.utils.constant.TestConstant;
+
+import com.sun.management.HotSpotDiagnosticMXBean;
+import org.apache.tsfile.utils.RamUsageEstimator;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.TreeMap;
+
+public class WALFileListCachePerformanceTest {
+
+  private static final String ENABLED_PROPERTY = 
"iotdb.wal.file-list-cache.perf.enabled";
+  private static final String FILE_COUNTS_PROPERTY = 
"iotdb.wal.file-list-cache.perf.file-counts";
+  private static final String WARMUP_READS_PROPERTY = 
"iotdb.wal.file-list-cache.perf.warmup-reads";
+  private static final String READS_PROPERTY = 
"iotdb.wal.file-list-cache.perf.reads";
+  private static final String ROUNDS_PROPERTY = 
"iotdb.wal.file-list-cache.perf.rounds";
+
+  private static final String BASE_DIRECTORY =
+      TestConstant.BASE_OUTPUT_PATH.concat("wal-file-list-cache-performance");
+  private static final int[] DEFAULT_FILE_COUNTS = {1, 10, 100, 1000};
+
+  private static final IoTDBConfig CONFIG = 
IoTDBDescriptor.getInstance().getConfig();
+
+  private static final long TREE_MAP_ENTRY_SHALLOW_SIZE = 
getTreeMapEntryShallowSize();
+  private static final long FILE_SHALLOW_SIZE = 
RamUsageEstimator.shallowSizeOfInstance(File.class);
+  private static final long LONG_SHALLOW_SIZE = 
RamUsageEstimator.shallowSizeOfInstance(Long.class);
+  private static final long STRING_SHALLOW_SIZE =
+      RamUsageEstimator.shallowSizeOfInstance(String.class);
+  private static final boolean COMPACT_STRINGS_ENABLED = 
isCompactStringsEnabled();
+
+  private static volatile long benchmarkBlackhole;
+
+  /**
+   * Compares repeated sorted-WAL-file reads with the cache disabled and 
enabled for increasing
+   * directory sizes. The cache-disabled path must rescan and sort on every 
read, while the enabled
+   * path should reuse the immutable snapshot after warmup.
+   */
+  @Test
+  public void benchmarkRepeatedSortedWalFileReads() throws Exception {
+    assumePerformanceTestEnabled();
+
+    final int[] fileCounts = parseFileCounts();
+    final int warmupReads = Integer.getInteger(WARMUP_READS_PROPERTY, 500);
+    final int reads = Integer.getInteger(READS_PROPERTY, 2000);
+    final int rounds = Integer.getInteger(ROUNDS_PROPERTY, 5);
+    Assert.assertTrue(warmupReads > 0);
+    Assert.assertTrue(reads > 0);
+    Assert.assertTrue(rounds > 0);
+
+    final boolean originalCacheEnabled = CONFIG.isWalFileListCacheEnabled();
+    EnvironmentUtils.cleanDir(BASE_DIRECTORY);
+    try {
+      for (int fileCount : fileCounts) {
+        runScenario(fileCount, warmupReads, reads, rounds);
+      }
+    } finally {
+      CONFIG.setWalFileListCacheEnabled(originalCacheEnabled);
+      EnvironmentUtils.cleanDir(BASE_DIRECTORY);
+    }
+  }
+
+  /**
+   * Estimates the retained heap added by enabling the cache for increasing 
WAL directory sizes. The
+   * estimate uses the current JVM object layout and the actual cached file 
paths, and separates the
+   * incrementally maintained index from the snapshot that is retained only 
after first access.
+   */
+  @Test
+  public void measureRetainedWalFileListCacheMemory() throws Exception {
+    assumePerformanceTestEnabled();
+
+    final boolean originalCacheEnabled = CONFIG.isWalFileListCacheEnabled();
+    EnvironmentUtils.cleanDir(BASE_DIRECTORY);
+    try {
+      for (int fileCount : parseFileCounts()) {
+        runMemoryScenario(fileCount);
+      }
+    } finally {
+      CONFIG.setWalFileListCacheEnabled(originalCacheEnabled);
+      EnvironmentUtils.cleanDir(BASE_DIRECTORY);
+    }
+  }
+
+  private static void runMemoryScenario(int fileCount) throws Exception {
+    Assert.assertTrue(fileCount > 0);
+    final WALNode cacheDisabled = createWalNode(fileCount, false);
+    final WALNode cacheEnabled = createWalNode(fileCount, true);
+    try {
+      Assert.assertNull(cacheDisabled.getCachedSortedWalFiles());
+      Assert.assertNull(cacheEnabled.getCachedSortedWalFiles());
+      assertFileList(cacheDisabled.getSortedWalFilesForTest(), fileCount);
+      assertFileList(cacheEnabled.getSortedWalFilesForTest(), fileCount);
+      Assert.assertNull(cacheDisabled.getCachedSortedWalFiles());
+
+      final File[] cachedWalFiles = cacheEnabled.getCachedSortedWalFiles();
+      Assert.assertNotNull(cachedWalFiles);
+      assertFileList(cachedWalFiles, fileCount);
+
+      final long entryBytes = TREE_MAP_ENTRY_SHALLOW_SIZE * 
cachedWalFiles.length;
+      final long fileBytes = FILE_SHALLOW_SIZE * cachedWalFiles.length;
+      long keyBytes = 0;
+      long pathBytes = 0;
+      long pathCharacters = 0;
+      for (File walFile : cachedWalFiles) {
+        pathBytes += estimateStringBytes(walFile.getPath());
+        pathCharacters += walFile.getPath().length();
+        final long versionId = WALFileUtils.parseVersionId(walFile.getName());
+        // Long values in this range come from the JVM-wide cache and are not 
retained by this
+        // feature.
+        if (versionId < -128 || versionId > 127) {
+          keyBytes += LONG_SHALLOW_SIZE;
+        }
+      }
+      final long indexBytes = entryBytes + fileBytes + keyBytes + pathBytes;
+      // TreeMap.values() is only a temporary view while publishing the array 
snapshot.
+      final long snapshotBytes = 
RamUsageEstimator.sizeOfObjectArray(cachedWalFiles.length);
+      final long totalBytes = indexBytes + snapshotBytes;
+      System.out.printf(
+          Locale.ROOT,
+          "WAL file-list cache retained-memory estimate: files=%d%n"
+              + "  cache=false retained=0 B%n"
+              + "  cache=true  index=%d B (%.3f KiB), lazy-snapshot=%d B (%.3f 
KiB), "
+              + "total=%d B (%.3f KiB), total/file=%.2f B%n"
+              + "  breakdown   entries=%d B, keys=%d B, files=%d B, paths=%d 
B, "
+              + "avg-path=%.1f chars, compact-strings=%s%n",
+          fileCount,
+          indexBytes,
+          bytesToKiB(indexBytes),
+          snapshotBytes,
+          bytesToKiB(snapshotBytes),
+          totalBytes,
+          bytesToKiB(totalBytes),
+          (double) totalBytes / fileCount,
+          entryBytes,
+          keyBytes,
+          fileBytes,
+          pathBytes,
+          (double) pathCharacters / fileCount,
+          COMPACT_STRINGS_ENABLED);
+    } finally {
+      cacheDisabled.close();
+      cacheEnabled.close();
+    }
+  }
+
+  private static void runScenario(int fileCount, int warmupReads, int reads, 
int rounds)
+      throws Exception {
+    Assert.assertTrue(fileCount > 0);
+    final WALNode cacheDisabled = createWalNode(fileCount, false);
+    final WALNode cacheEnabled = createWalNode(fileCount, true);
+    try {
+      assertFileList(cacheDisabled.getSortedWalFilesForTest(), fileCount);
+      assertFileList(cacheEnabled.getSortedWalFilesForTest(), fileCount);
+
+      runReads(cacheDisabled, warmupReads);
+      runReads(cacheEnabled, warmupReads);
+
+      final long[] cacheDisabledNanos = new long[rounds];
+      final long[] cacheEnabledNanos = new long[rounds];
+      for (int round = 0; round < rounds; round++) {
+        if ((round & 1) == 0) {
+          cacheDisabledNanos[round] = measureReads(cacheDisabled, reads);
+          cacheEnabledNanos[round] = measureReads(cacheEnabled, reads);
+        } else {
+          cacheEnabledNanos[round] = measureReads(cacheEnabled, reads);
+          cacheDisabledNanos[round] = measureReads(cacheDisabled, reads);
+        }
+      }
+
+      final double cacheDisabledNanosPerRead = median(cacheDisabledNanos) / 
reads;
+      final double cacheEnabledNanosPerRead = median(cacheEnabledNanos) / 
reads;
+      System.out.printf(
+          Locale.ROOT,
+          "WAL sorted-file-list benchmark: files=%d, warmup-reads=%d, 
reads/round=%d, rounds=%d%n",
+          fileCount,
+          warmupReads,
+          reads,
+          rounds);
+      printResult("cache=false", cacheDisabledNanosPerRead);
+      printResult("cache=true", cacheEnabledNanosPerRead);
+      System.out.printf(
+          Locale.ROOT,
+          "  speedup=%.2fx, latency reduction=%.2f%%%n",
+          cacheDisabledNanosPerRead / cacheEnabledNanosPerRead,
+          (cacheDisabledNanosPerRead - cacheEnabledNanosPerRead)
+              * 100.0
+              / cacheDisabledNanosPerRead);
+    } finally {
+      cacheDisabled.close();
+      cacheEnabled.close();
+    }
+  }
+
+  private static WALNode createWalNode(int fileCount, boolean cacheEnabled) 
throws IOException {
+    final String state = cacheEnabled ? "enabled" : "disabled";
+    final String directory = BASE_DIRECTORY + File.separator + fileCount + 
File.separator + state;
+    EnvironmentUtils.cleanDir(directory);
+    final File directoryFile = new File(directory);
+    Assert.assertTrue(directoryFile.mkdirs() || directoryFile.isDirectory());
+    for (int version = 0; version < fileCount - 1; version++) {
+      final File walFile =
+          new File(
+              directoryFile,
+              WALFileUtils.getLogFileName(version, version, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
+      Assert.assertTrue(walFile.createNewFile());
+    }
+
+    CONFIG.setWalFileListCacheEnabled(cacheEnabled);
+    return new WALNode(
+        "wal-file-list-cache-performance-" + fileCount + '-' + state,
+        directory,
+        fileCount - 1L,
+        fileCount - 1L);
+  }
+
+  private static long measureReads(WALNode walNode, int reads) {
+    final long startNanos = System.nanoTime();
+    runReads(walNode, reads);
+    return System.nanoTime() - startNanos;
+  }
+
+  private static void runReads(WALNode walNode, int reads) {
+    long checksum = 0;
+    for (int i = 0; i < reads; i++) {
+      final File[] walFiles = walNode.getSortedWalFilesForTest();
+      checksum += walFiles.length;
+      checksum += walFiles[walFiles.length - 1].getName().length();
+    }
+    benchmarkBlackhole = checksum;
+  }
+
+  private static void assertFileList(File[] walFiles, int expectedFileCount) {
+    Assert.assertEquals(expectedFileCount, walFiles.length);
+    for (int i = 0; i < walFiles.length; i++) {
+      Assert.assertEquals(i, 
WALFileUtils.parseVersionId(walFiles[i].getName()));
+    }
+  }
+
+  private static long getTreeMapEntryShallowSize() {
+    final TreeMap<Long, File> sample = new TreeMap<>();
+    sample.put(0L, new File("sample.wal"));
+    return 
RamUsageEstimator.shallowSizeOf(sample.entrySet().iterator().next());
+  }
+
+  private static long estimateStringBytes(String value) {
+    final boolean latin1 = value.chars().allMatch(character -> character <= 
0xFF);
+    final int bytesPerCharacter = COMPACT_STRINGS_ENABLED && latin1 ? 1 : 2;
+    return STRING_SHALLOW_SIZE
+        + RamUsageEstimator.sizeOfByteArray(value.length() * 
bytesPerCharacter);
+  }
+
+  private static boolean isCompactStringsEnabled() {
+    try {
+      return Boolean.parseBoolean(
+          ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class)
+              .getVMOption("CompactStrings")
+              .getValue());
+    } catch (RuntimeException ignored) {
+      // The conservative fallback models two bytes per UTF-16 code unit.
+      return false;
+    }
+  }
+
+  private static double bytesToKiB(long bytes) {
+    return bytes / 1024.0;
+  }
+
+  private static void assumePerformanceTestEnabled() {
+    Assume.assumeTrue(
+        String.format(
+            Locale.ROOT,
+            "Manual performance UT. Enable with -D%s=true; optionally tune 
-D%s, -D%s, -D%s, and -D%s.",
+            ENABLED_PROPERTY,
+            FILE_COUNTS_PROPERTY,
+            WARMUP_READS_PROPERTY,
+            READS_PROPERTY,
+            ROUNDS_PROPERTY),
+        Boolean.getBoolean(ENABLED_PROPERTY));
+  }
+
+  private static int[] parseFileCounts() {
+    final String configured = System.getProperty(FILE_COUNTS_PROPERTY);
+    if (configured == null || configured.trim().isEmpty()) {
+      return DEFAULT_FILE_COUNTS;
+    }
+    return Arrays.stream(configured.split(","))
+        .map(String::trim)
+        .mapToInt(Integer::parseInt)
+        .toArray();
+  }
+
+  private static double median(long[] values) {
+    Arrays.sort(values);
+    final int middle = values.length / 2;
+    return (values.length & 1) == 1
+        ? values[middle]
+        : values[middle - 1] + (values[middle] - values[middle - 1]) / 2.0;
+  }
+
+  private static void printResult(String label, double nanosPerRead) {
+    System.out.printf(
+        Locale.ROOT,
+        "  %-12s latency=%.3f us/read, throughput=%.0f reads/s%n",
+        label,
+        nanosPerRead / 1000.0,
+        1_000_000_000.0 / nanosPerRead);
+  }
+}
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WalDeleteOutdatedNewTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WalDeleteOutdatedNewTest.java
index 2f57db238b0..ab95d7f067e 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WalDeleteOutdatedNewTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/dataregion/wal/node/WalDeleteOutdatedNewTest.java
@@ -62,6 +62,7 @@ public class WalDeleteOutdatedNewTest {
   private static final String dataRegionId = "1";
   private WALMode prevMode;
   private String prevConsensus;
+  private boolean prevWalFileListCacheEnabled;
   private WALNode walNode1;
 
   @Before
@@ -69,8 +70,10 @@ public class WalDeleteOutdatedNewTest {
     EnvironmentUtils.cleanDir(logDirectory1);
     prevMode = config.getWalMode();
     prevConsensus = config.getDataRegionConsensusProtocolClass();
+    prevWalFileListCacheEnabled = config.isWalFileListCacheEnabled();
     config.setWalMode(WALMode.SYNC);
     
config.setDataRegionConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS);
+    config.setWalFileListCacheEnabled(true);
     walNode1 = new WALNode(identifier1, logDirectory1);
     DataRegion dataRegion = new DataRegionTest.DummyDataRegion(logDirectory1, 
databasePath);
     dataRegion.updatePartitionFileVersion(2911, 0);
@@ -82,6 +85,7 @@ public class WalDeleteOutdatedNewTest {
     walNode1.close();
     config.setWalMode(prevMode);
     config.setDataRegionConsensusProtocolClass(prevConsensus);
+    config.setWalFileListCacheEnabled(prevWalFileListCacheEnabled);
     EnvironmentUtils.cleanDir(logDirectory1);
     StorageEngine.getInstance().reset();
   }
@@ -214,6 +218,10 @@ public class WalDeleteOutdatedNewTest {
     Assert.assertEquals(2, memTableIdsOfWal.get(0L).size());
     File[] files = WALFileUtils.listAllWALFiles(new File(logDirectory1));
     Assert.assertEquals(2, files.length);
+    Assert.assertNull(walNode1.getCachedSortedWalFiles());
+    walNode1.getDeletionBoundToFreeAtLeast(1);
+    File[] cachedWalFilesBeforeDeletion = walNode1.getCachedSortedWalFiles();
+    Assert.assertEquals(2, cachedWalFilesBeforeDeletion.length);
 
     walNode1.deleteOutdatedFiles();
     Map<Long, Set<Long>> memTableIdsOfWalAfter = 
walNode1.getWALBuffer().getMemTableIdsOfWal();
@@ -222,6 +230,10 @@ public class WalDeleteOutdatedNewTest {
     Assert.assertEquals(0, memTableIdsOfWalAfter.size());
     File[] filesAfter = WALFileUtils.listAllWALFiles(new File(logDirectory1));
     Assert.assertEquals(1, filesAfter.length);
+    Assert.assertEquals(1, walNode1.getCachedSortedWalFiles().length);
+    Assert.assertEquals(
+        walNode1.getCurrentWALFileVersion(),
+        
WALFileUtils.parseVersionId(walNode1.getCachedSortedWalFiles()[0].getName()));
   }
 
   /**
diff --git 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
index c4a06b68b14..e325ff42573 100644
--- 
a/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
+++ 
b/iotdb-core/node-commons/src/assembly/resources/conf/iotdb-system.properties.template
@@ -1705,6 +1705,15 @@ wal_sync_mode_fsync_delay_in_ms=3
 # Datatype: int
 wal_buffer_size_in_byte=33554432
 
+# Whether to cache the sorted WAL file list in each WAL node.
+# Enabling this avoids scanning and sorting the WAL directory whenever the 
file list is accessed,
+# including when a consensus request reader updates its files to search.
+# Keeping this enabled is especially recommended when IoTConsensus 
synchronizes data through WAL
+# and synchronization latency is observed.
+# effectiveMode: restart
+# Datatype: boolean
+wal_file_list_cache_enabled=true
+
 # Size threshold of each wal file
 # When a wal file's size exceeds this, the wal file will be closed and a new 
wal file will be created.
 # If it's a value smaller than 0, use the default value 30 * 1024 * 1024 
(30MB).

Reply via email to