You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iotdb.apache.org by ju...@apache.org on 2026/08/25 03:09:25 UTC

(iotdb) 01/01: [Subscription] Bound consensus prefetch memory usage (#18298)

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

Caideyipi pushed a commit to branch rc-to-20111
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit 4baf19f21a2579583e83f82da568454206a07240
Author: Caideyipi <87...@users.noreply.github.com>
AuthorDate: Tue Jul 28 18:35:32 2026 +0800

    [Subscription] Bound consensus prefetch memory usage (#18298)
    
    * [Subscription] Bound consensus prefetch memory usage
    
    * [Subscription] Accept fenced consensus commits as no-ops
    
    * [Subscription] Address review comments
    
    (cherry picked from commit 44b2cf5676aa21f9aac8a6c7d28773f03659db72)
---
 .../apache/iotdb/db/i18n/DataNodeMiscMessages.java |   9 +
 .../apache/iotdb/db/i18n/DataNodeMiscMessages.java |   9 +
 .../apache/iotdb/db/conf/DataNodeMemoryConfig.java | 137 ++--
 .../metrics/memory/QueryEngineMemoryMetrics.java   |  26 +-
 .../broker/ConsensusSubscriptionBroker.java        |  21 +-
 .../consensus/ConsensusPrefetchingQueue.java       | 737 ++++++++++++++++-----
 .../SubscriptionDataNodeResourceManager.java       |   6 +
 .../resource/SubscriptionMemoryManager.java        |  89 +++
 .../iotdb/db/conf/DataNodeMemoryConfigTest.java    |  69 ++
 ...onsensusPrefetchingQueueDataNodeMemoryTest.java | 346 ++++++++++
 .../consensus/ConsensusPrefetchingQueueTest.java   | 697 ++++++++++++++++++-
 ...nsensusPrefetchingQueueWalBackpressureTest.java | 336 ++++++++++
 .../resource/SubscriptionMemoryManagerTest.java    |  64 ++
 .../conf/iotdb-system.properties.template          |   8 +-
 14 files changed, 2343 insertions(+), 211 deletions(-)

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 b5011edc8bd..a74d9cc7676 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
@@ -1392,6 +1392,15 @@ public final class DataNodeMiscMessages {
   public static final String MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_UDF_READER_TRANSFORMER_97CA8962 =
       "Each subsection of configuration item udf_reader_transformer_collector_memory_proportion "
           + "should be an integer, which is %s";
+  public static final String
+      EXCEPTION_QUERY_MEMORY_PROPORTIONS_MUST_CONTAIN_8_OR_9_COLON_SEPARATED_VALUES_BUT_FOUND_ARG_03A03941 =
+          "Query memory proportions must contain 8 or 9 colon-separated values, but found %d";
+  public static final String
+      EXCEPTION_QUERY_MEMORY_PROPORTION_AT_POSITION_ARG_MUST_BE_NON_NEGATIVE_BUT_FOUND_ARG_DC69BC75 =
+          "Query memory proportion at position %d must be non-negative, but found %d";
+  public static final String
+      EXCEPTION_THE_SUM_OF_QUERY_MEMORY_PROPORTIONS_MUST_BE_POSITIVE_BUT_WAS_ARG_407092B6 =
+          "The sum of query memory proportions must be positive, but was %d";
   public static final String MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_CHUNKMETA_CHUNK_TIMESERIESMETA_77A43CE2 =
       "Each subsection of configuration item chunkmeta_chunk_timeseriesmeta_free_memory_proportion "
           + "should be an integer, which is %s";
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 8983fd61dc4..31ab68978df 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
@@ -1362,6 +1362,15 @@ public final class DataNodeMiscMessages {
       "无法重新加载配置文件 %s,原因:%s";
   public static final String MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_UDF_READER_TRANSFORMER_97CA8962 =
       "配置项 udf_reader_transformer_collector_memory_proportion 的每个子项都应为整数,当前为 %s";
+  public static final String
+      EXCEPTION_QUERY_MEMORY_PROPORTIONS_MUST_CONTAIN_8_OR_9_COLON_SEPARATED_VALUES_BUT_FOUND_ARG_03A03941 =
+          "查询内存比例必须包含 8 个或 9 个以冒号分隔的值,但实际有 %d 个";
+  public static final String
+      EXCEPTION_QUERY_MEMORY_PROPORTION_AT_POSITION_ARG_MUST_BE_NON_NEGATIVE_BUT_FOUND_ARG_DC69BC75 =
+          "查询内存比例中第 %d 个值必须为非负数,但实际为 %d";
+  public static final String
+      EXCEPTION_THE_SUM_OF_QUERY_MEMORY_PROPORTIONS_MUST_BE_POSITIVE_BUT_WAS_ARG_407092B6 =
+          "查询内存比例之和必须为正数,但实际为 %d";
   public static final String MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_CHUNKMETA_CHUNK_TIMESERIESMETA_77A43CE2 =
       "配置项 chunkmeta_chunk_timeseriesmeta_free_memory_proportion 的每个子项都应为整数,当前为 %s";
   public static final String MISC_EXCEPTION_ILLEGAL_DEFAULTDATABASELEVEL_D_SHOULD_1_03088B38 =
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/DataNodeMemoryConfig.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/DataNodeMemoryConfig.java
index 9476e84e15e..350f5d78019 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/DataNodeMemoryConfig.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/DataNodeMemoryConfig.java
@@ -33,6 +33,13 @@ import org.slf4j.LoggerFactory;
 public class DataNodeMemoryConfig {
   private static final Logger LOGGER = LoggerFactory.getLogger(DataNodeMemoryConfig.class);
 
+  private static final int LEGACY_QUERY_MEMORY_COMPONENT_COUNT = 8;
+  private static final int QUERY_MEMORY_COMPONENT_COUNT = 9;
+  private static final int SUBSCRIPTION_MEMORY_INDEX = 8;
+  private static final int[] DEFAULT_QUERY_MEMORY_PROPORTIONS = {
+    1, 100, 200, 50, 200, 200, 200, 50, 250
+  };
+
   public static final String SCHEMA_CACHE = "SchemaCache";
   public static final String SCHEMA_REGION = "SchemaRegion";
   public static final String PARTITION_CACHE = "PartitionCache";
@@ -128,6 +135,9 @@ public class DataNodeMemoryConfig {
   /** Memory manager proportion for timeIndex */
   private MemoryManager timeIndexMemoryManager;
 
+  /** Memory manager for subscription */
+  private MemoryManager subscriptionMemoryManager;
+
   /** Memory manager for the mtree */
   private MemoryManager schemaEngineMemoryManager;
 
@@ -429,6 +439,55 @@ public class DataNodeMemoryConfig {
             "Compaction", compactionMemorySize - fixedMemoryCost);
   }
 
+  static int[] resolveQueryMemoryProportions(
+      final String configuredProportions, final boolean subscriptionEnabled) {
+    final int[] resolvedProportions = DEFAULT_QUERY_MEMORY_PROPORTIONS.clone();
+    if (configuredProportions != null) {
+      final String[] proportions = configuredProportions.split(":");
+      if (proportions.length != LEGACY_QUERY_MEMORY_COMPONENT_COUNT
+          && proportions.length != QUERY_MEMORY_COMPONENT_COUNT) {
+        throw new IllegalArgumentException(
+            String.format(
+                DataNodeMiscMessages
+                    .EXCEPTION_QUERY_MEMORY_PROPORTIONS_MUST_CONTAIN_8_OR_9_COLON_SEPARATED_VALUES_BUT_FOUND_ARG_03A03941,
+                proportions.length));
+      }
+      for (int i = 0; i < proportions.length; i++) {
+        resolvedProportions[i] = Integer.parseInt(proportions[i].trim());
+        if (resolvedProportions[i] < 0) {
+          throw new IllegalArgumentException(
+              String.format(
+                  DataNodeMiscMessages
+                      .EXCEPTION_QUERY_MEMORY_PROPORTION_AT_POSITION_ARG_MUST_BE_NON_NEGATIVE_BUT_FOUND_ARG_DC69BC75,
+                  i + 1,
+                  resolvedProportions[i]));
+        }
+      }
+      if (proportions.length == LEGACY_QUERY_MEMORY_COMPONENT_COUNT) {
+        int legacyProportionSum = 0;
+        for (int i = 0; i < LEGACY_QUERY_MEMORY_COMPONENT_COUNT; i++) {
+          legacyProportionSum += resolvedProportions[i];
+        }
+        resolvedProportions[SUBSCRIPTION_MEMORY_INDEX] = legacyProportionSum / 4;
+      }
+    }
+    if (!subscriptionEnabled) {
+      resolvedProportions[SUBSCRIPTION_MEMORY_INDEX] = 0;
+    }
+    int proportionSum = 0;
+    for (final int proportion : resolvedProportions) {
+      proportionSum += proportion;
+    }
+    if (proportionSum <= 0) {
+      throw new IllegalArgumentException(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_THE_SUM_OF_QUERY_MEMORY_PROPORTIONS_MUST_BE_POSITIVE_BUT_WAS_ARG_407092B6,
+              proportionSum));
+    }
+    return resolvedProportions;
+  }
+
   @SuppressWarnings("squid:S3518")
   private void initQueryEngineMemoryAllocate(
       MemoryManager queryEngineMemoryManager, TrimProperties properties) {
@@ -459,49 +518,40 @@ public class DataNodeMemoryConfig {
       LOGGER.error(String.format(DataNodeMiscMessages.FAIL_RELOAD_CONFIGURATION_FMT, e));
     }
 
+    long maxMemoryAvailable = queryEngineMemoryManager.getTotalMemorySizeInBytes();
     String queryMemoryAllocateProportion =
         properties.getProperty("chunk_timeseriesmeta_free_memory_proportion");
-    long maxMemoryAvailable = queryEngineMemoryManager.getTotalMemorySizeInBytes();
-
-    long bloomFilterCacheMemorySize = maxMemoryAvailable / 1001;
-    long chunkCacheMemorySize = maxMemoryAvailable * 100 / 1001;
-    long timeSeriesMetaDataCacheMemorySize = maxMemoryAvailable * 200 / 1001;
-    long coordinatorMemorySize = maxMemoryAvailable * 50 / 1001;
-    long operatorsMemorySize = maxMemoryAvailable * 200 / 1001;
-    long dataExchangeMemorySize = maxMemoryAvailable * 200 / 1001;
-    long timeIndexMemorySize = maxMemoryAvailable * 200 / 1001;
-    if (queryMemoryAllocateProportion != null) {
-      String[] proportions = queryMemoryAllocateProportion.split(":");
-      int proportionSum = 0;
-      for (String proportion : proportions) {
-        proportionSum += Integer.parseInt(proportion.trim());
-      }
-      if (proportionSum != 0) {
-        try {
-          bloomFilterCacheMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[0].trim()) / proportionSum;
-          chunkCacheMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[1].trim()) / proportionSum;
-          timeSeriesMetaDataCacheMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[2].trim()) / proportionSum;
-          coordinatorMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[3].trim()) / proportionSum;
-          operatorsMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[4].trim()) / proportionSum;
-          dataExchangeMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[5].trim()) / proportionSum;
-          timeIndexMemorySize =
-              maxMemoryAvailable * Integer.parseInt(proportions[6].trim()) / proportionSum;
-        } catch (Exception e) {
-          throw new IllegalArgumentException(
-              String.format(
-                  DataNodeMiscMessages
-                      .MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_CHUNKMETA_CHUNK_TIMESERIESMETA_77A43CE2,
-                  queryMemoryAllocateProportion),
-              e);
-        }
-      }
+    boolean subscriptionEnabled =
+        Boolean.parseBoolean(
+            properties.getProperty("subscription_enabled", Boolean.TRUE.toString()));
+    final int[] queryMemoryProportions;
+    try {
+      queryMemoryProportions =
+          resolveQueryMemoryProportions(queryMemoryAllocateProportion, subscriptionEnabled);
+    } catch (Exception e) {
+      throw new IllegalArgumentException(
+          String.format(
+              DataNodeMiscMessages
+                  .MISC_EXCEPTION_EACH_SUBSECTION_OF_CONFIGURATION_ITEM_CHUNKMETA_CHUNK_TIMESERIESMETA_77A43CE2,
+              queryMemoryAllocateProportion),
+          e);
     }
+    int proportionSum = 0;
+    for (int proportion : queryMemoryProportions) {
+      proportionSum += proportion;
+    }
+
+    long bloomFilterCacheMemorySize =
+        maxMemoryAvailable * queryMemoryProportions[0] / proportionSum;
+    long chunkCacheMemorySize = maxMemoryAvailable * queryMemoryProportions[1] / proportionSum;
+    long timeSeriesMetaDataCacheMemorySize =
+        maxMemoryAvailable * queryMemoryProportions[2] / proportionSum;
+    long coordinatorMemorySize = maxMemoryAvailable * queryMemoryProportions[3] / proportionSum;
+    long operatorsMemorySize = maxMemoryAvailable * queryMemoryProportions[4] / proportionSum;
+    long dataExchangeMemorySize = maxMemoryAvailable * queryMemoryProportions[5] / proportionSum;
+    long timeIndexMemorySize = maxMemoryAvailable * queryMemoryProportions[6] / proportionSum;
+    long subscriptionMemorySize =
+        maxMemoryAvailable * queryMemoryProportions[SUBSCRIPTION_MEMORY_INDEX] / proportionSum;
 
     // metadata cache is disabled, we need to move all their allocated memory to other parts
     if (!isMetaDataCacheEnable()) {
@@ -532,6 +582,9 @@ public class DataNodeMemoryConfig {
         queryEngineMemoryManager.getOrCreateMemoryManager("DataExchange", dataExchangeMemorySize);
     timeIndexMemoryManager =
         queryEngineMemoryManager.getOrCreateMemoryManager("TimeIndex", timeIndexMemorySize);
+    subscriptionMemoryManager =
+        queryEngineMemoryManager.getOrCreateMemoryManager(
+            "Subscription", subscriptionMemorySize, true);
 
     // must be called after dataExchangeMemoryManager being inited.
     setQueryThreadCount(
@@ -695,6 +748,10 @@ public class DataNodeMemoryConfig {
     return timeIndexMemoryManager;
   }
 
+  public MemoryManager getSubscriptionMemoryManager() {
+    return subscriptionMemoryManager;
+  }
+
   public MemoryManager getSchemaEngineMemoryManager() {
     return schemaEngineMemoryManager;
   }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/service/metrics/memory/QueryEngineMemoryMetrics.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/service/metrics/memory/QueryEngineMemoryMetrics.java
index 7138f0daad0..d336c86bc91 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/service/metrics/memory/QueryEngineMemoryMetrics.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/service/metrics/memory/QueryEngineMemoryMetrics.java
@@ -43,6 +43,7 @@ public class QueryEngineMemoryMetrics implements IMetricSet {
   private static final String QUERY_ENGINE_DATA_EXCHANGE = "QueryEngine-DataExchange";
   private static final String QUERY_ENGINE_TIME_INDEX = "QueryEngine-TimeIndex";
   private static final String QUERY_ENGINE_COORDINATOR = "QueryEngine-Coordinator";
+  private static final String QUERY_ENGINE_SUBSCRIPTION = "QueryEngine-Subscription";
 
   @Override
   public void bindTo(AbstractMetricService metricService) {
@@ -211,6 +212,28 @@ public class QueryEngineMemoryMetrics implements IMetricSet {
         GlobalMemoryMetrics.ON_HEAP,
         Tag.LEVEL.toString(),
         GlobalMemoryMetrics.LEVELS[2]);
+    metricService.createAutoGauge(
+        Metric.MEMORY_THRESHOLD_SIZE.toString(),
+        MetricLevel.IMPORTANT,
+        memoryConfig.getSubscriptionMemoryManager(),
+        MemoryManager::getTotalMemorySizeInBytes,
+        Tag.NAME.toString(),
+        QUERY_ENGINE_SUBSCRIPTION,
+        Tag.TYPE.toString(),
+        GlobalMemoryMetrics.ON_HEAP,
+        Tag.LEVEL.toString(),
+        GlobalMemoryMetrics.LEVELS[2]);
+    metricService.createAutoGauge(
+        Metric.MEMORY_ACTUAL_SIZE.toString(),
+        MetricLevel.IMPORTANT,
+        memoryConfig.getSubscriptionMemoryManager(),
+        MemoryManager::getUsedMemorySizeInBytes,
+        Tag.NAME.toString(),
+        QUERY_ENGINE_SUBSCRIPTION,
+        Tag.TYPE.toString(),
+        GlobalMemoryMetrics.ON_HEAP,
+        Tag.LEVEL.toString(),
+        GlobalMemoryMetrics.LEVELS[2]);
   }
 
   @Override
@@ -231,7 +254,8 @@ public class QueryEngineMemoryMetrics implements IMetricSet {
             QUERY_ENGINE_OPERATORS,
             QUERY_ENGINE_DATA_EXCHANGE,
             QUERY_ENGINE_TIME_INDEX,
-            QUERY_ENGINE_COORDINATOR)
+            QUERY_ENGINE_COORDINATOR,
+            QUERY_ENGINE_SUBSCRIPTION)
         .forEach(
             name -> {
               metricService.remove(
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
index e089a8426d3..98dda3709f3 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
@@ -249,15 +249,22 @@ public class ConsensusSubscriptionBroker implements ISubscriptionBroker {
           getQueueForCommitContext(queues, commitContext);
       boolean handled = false;
       if (Objects.nonNull(assignedQueue)) {
-        final boolean success;
-        if (!nack) {
-          success = assignedQueue.ackSilent(consumerId, commitContext);
-        } else {
-          success = assignedQueue.nackSilent(consumerId, commitContext);
-        }
-        if (success) {
+        if (assignedQueue.isCommitContextOutdated(commitContext)) {
+          // A seek or reboot has already fenced this context. Accept the delayed commit as a no-op
+          // so the client does not retry it, while keeping the current commit frontier unchanged.
           successfulCommitContexts.add(commitContext);
           handled = true;
+        } else {
+          final boolean success;
+          if (!nack) {
+            success = assignedQueue.ackSilent(consumerId, commitContext);
+          } else {
+            success = assignedQueue.nackSilent(consumerId, commitContext);
+          }
+          if (success) {
+            successfulCommitContexts.add(commitContext);
+            handled = true;
+          }
         }
       }
       if (!handled) {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
index cc03f1941bc..e00a3c5b21d 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
@@ -21,6 +21,7 @@ package org.apache.iotdb.db.subscription.broker.consensus;
 
 import org.apache.iotdb.commons.consensus.ConsensusGroupId;
 import org.apache.iotdb.commons.subscription.config.SubscriptionConfig;
+import org.apache.iotdb.commons.utils.TestOnly;
 import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
 import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
 import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
@@ -44,6 +45,8 @@ import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
 import org.apache.iotdb.db.subscription.metric.ConsensusSubscriptionPrefetchingQueueMetrics;
+import org.apache.iotdb.db.subscription.resource.SubscriptionDataNodeResourceManager;
+import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager;
 import org.apache.iotdb.db.subscription.task.execution.ConsensusSubscriptionPrefetchExecutor;
 import org.apache.iotdb.db.subscription.task.execution.ConsensusSubscriptionPrefetchExecutorManager;
 import org.apache.iotdb.db.subscription.task.subtask.ConsensusPrefetchSubtask;
@@ -86,6 +89,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
@@ -114,6 +118,8 @@ public class ConsensusPrefetchingQueue {
 
   private final ConsensusSubscriptionCommitManager commitManager;
 
+  private SubscriptionMemoryManager subscriptionMemoryManager;
+
   /**
    * Incremented on each seek to distinguish batches/events created before and after the seek. The
    * value is copied into commit contexts so stale events cannot be committed after a later seek.
@@ -127,6 +133,26 @@ public class ConsensusPrefetchingQueue {
 
   private final Map<InFlightEventKey, SubscriptionEvent> inFlightEvents;
 
+  /** Tablet memory retained by queued and in-flight events, keyed by event identity. */
+  private final Map<SubscriptionEvent, Long> retainedBytesByEvent = new ConcurrentHashMap<>();
+
+  /** Materialized data events indexed across both queued and in-flight lifecycle stages. */
+  private final Map<SubscriptionCommitContext, SubscriptionEvent> outstandingEventsByCommitContext =
+      new ConcurrentHashMap<>();
+
+  /** Tablet memory retained by this queue across all materialized lifecycle stages. */
+  private final AtomicLong retainedTabletBytes = new AtomicLong(0L);
+
+  /**
+   * Size of the first request that could not be materialized because of the shared memory limit.
+   * This prevents repeatedly converting the same request while some memory is free but still less
+   * than the request requires.
+   */
+  private volatile long memoryBlockedEntryBytes = -1L;
+
+  /** Rejects the real-time fast path while this queue cannot safely materialize more data. */
+  private final AtomicBoolean realtimeAdmissionBlocked = new AtomicBoolean(false);
+
   private static final int MAX_PREFETCHING_QUEUE_SIZE =
       SubscriptionConfig.getInstance().getSubscriptionConsensusPrefetchingQueueCapacity();
 
@@ -141,6 +167,11 @@ public class ConsensusPrefetchingQueue {
 
   private volatile boolean closeRequested = false;
 
+  /**
+   * Allows a later close call to finish cleanup after a fatal prefetch failure marked the queue.
+   */
+  private volatile boolean closeCleanupPending = false;
+
   private volatile boolean isActive = true;
 
   private volatile Set<Integer> activeWriterNodeIds = Collections.emptySet();
@@ -198,8 +229,14 @@ public class ConsensusPrefetchingQueue {
    */
   private volatile boolean prefetchInitialized = false;
 
+  /** Serializes lazy initialization without participating in queue lifecycle lock ordering. */
+  private final Object prefetchInitializationLock = new Object();
+
   private volatile PendingSeekRequest pendingSeekRequest;
 
+  /** Serializes lifecycle transitions independently from lazy prefetch initialization. */
+  private final Object runtimeActivationLock = new Object();
+
   private final DeliveryBatchState lingerBatch = new DeliveryBatchState();
 
   private volatile long observedSeekGeneration;
@@ -264,6 +301,19 @@ public class ConsensusPrefetchingQueue {
     LOCATE_MISS
   }
 
+  private enum MaterializationResult {
+    SUCCESS,
+    MEMORY_BLOCKED,
+    WAL_GAP,
+    STALE
+  }
+
+  private enum InFlightAckResult {
+    ACKED,
+    REJECTED,
+    MISSING
+  }
+
   protected static final class ReplayLocateDecision {
     private final ReplayLocateStatus status;
     private final long startSearchIndex;
@@ -324,15 +374,24 @@ public class ConsensusPrefetchingQueue {
       extends LinkedBlockingDeque<IndexedConsensusRequest> {
 
     private final Runnable wakeupHook;
+    private final BooleanSupplier admissionSupplier;
 
-    private WakeableIndexedConsensusQueue(final int capacity, final Runnable wakeupHook) {
+    private WakeableIndexedConsensusQueue(
+        final int capacity, final Runnable wakeupHook, final BooleanSupplier admissionSupplier) {
       super(capacity);
       this.wakeupHook = wakeupHook;
+      this.admissionSupplier = admissionSupplier;
     }
 
     @Override
     public boolean offer(final IndexedConsensusRequest request) {
-      final boolean offered = super.offer(request);
+      final boolean offered;
+      synchronized (this) {
+        if (!admissionSupplier.getAsBoolean()) {
+          return false;
+        }
+        offered = super.offer(request);
+      }
       if (offered) {
         wakeupHook.run();
       }
@@ -340,9 +399,8 @@ public class ConsensusPrefetchingQueue {
     }
 
     @Override
-    public void put(final IndexedConsensusRequest request) throws InterruptedException {
-      super.put(request);
-      wakeupHook.run();
+    public synchronized void clear() {
+      super.clear();
     }
   }
 
@@ -423,6 +481,7 @@ public class ConsensusPrefetchingQueue {
     this.retentionPolicy = retentionPolicy;
     this.converter = converter;
     this.commitManager = commitManager;
+    this.subscriptionMemoryManager = SubscriptionDataNodeResourceManager.memory();
     this.fallbackCommittedRegionProgress = fallbackCommittedRegionProgress;
     this.fallbackTailSearchIndex = tailStartSearchIndex;
     this.runtimeVersion = initialRuntimeVersion;
@@ -438,7 +497,8 @@ public class ConsensusPrefetchingQueue {
 
     // Register pending queue early so we don't miss real-time writes
     this.pendingEntries =
-        new WakeableIndexedConsensusQueue(PENDING_QUEUE_CAPACITY, this::requestPrefetch);
+        new WakeableIndexedConsensusQueue(
+            PENDING_QUEUE_CAPACITY, this::requestPrefetch, this::canAcceptRealtimeEntry);
     serverImpl.registerSubscriptionQueue(pendingEntries, retentionPolicy);
 
     LOGGER.info(
@@ -457,6 +517,16 @@ public class ConsensusPrefetchingQueue {
     ConsensusSubscriptionPrefetchingQueueMetrics.getInstance().register(this);
   }
 
+  @TestOnly
+  void setSubscriptionMemoryManager(final SubscriptionMemoryManager subscriptionMemoryManager) {
+    if (retainedTabletBytes.get() != 0L) {
+      throw new IllegalStateException();
+    }
+    this.subscriptionMemoryManager = Objects.requireNonNull(subscriptionMemoryManager);
+    memoryBlockedEntryBytes = -1L;
+    realtimeAdmissionBlocked.set(false);
+  }
+
   // ======================== Lock Operations ========================
 
   private void acquireReadLock() {
@@ -485,6 +555,24 @@ public class ConsensusPrefetchingQueue {
     }
   }
 
+  private boolean canAcceptRealtimeEntry() {
+    return isActive
+        && !closeRequested
+        && !isClosed
+        && !realtimeAdmissionBlocked.get()
+        && prefetchingQueue.size() < MAX_PREFETCHING_QUEUE_SIZE
+        && subscriptionMemoryManager.getFreeMemorySizeInBytes() > 0L;
+  }
+
+  private void blockRealtimeAdmission() {
+    realtimeAdmissionBlocked.set(true);
+    pendingEntries.clear();
+  }
+
+  private void unblockRealtimeAdmission() {
+    realtimeAdmissionBlocked.set(false);
+  }
+
   private ConsensusPrefetchSubtask ensurePrefetchSubtaskBound() {
     if (closeRequested || isClosed) {
       return null;
@@ -603,7 +691,13 @@ public class ConsensusPrefetchingQueue {
     }
   }
 
-  private synchronized void initPrefetch(final RegionProgress regionProgress) {
+  private void initPrefetch(final RegionProgress regionProgress) {
+    synchronized (prefetchInitializationLock) {
+      initPrefetchUnderInitializationLock(regionProgress);
+    }
+  }
+
+  private void initPrefetchUnderInitializationLock(final RegionProgress regionProgress) {
     if (prefetchInitialized) {
       return; // double-check under synchronization
     }
@@ -641,7 +735,7 @@ public class ConsensusPrefetchingQueue {
     }
     this.prefetchInitialized = true;
     this.observedSeekGeneration = seekGeneration.get();
-    this.lingerBatch.reset();
+    discardBatch(this.lingerBatch);
     resetBatchWriterProgress();
 
     LOGGER.info(
@@ -1021,9 +1115,13 @@ public class ConsensusPrefetchingQueue {
   }
 
   private void bufferRealtimeEntry(final PreparedEntry entry) {
-    realtimeEntriesByWriter
-        .computeIfAbsent(entry.writerNodeId, ignored -> new TreeMap<>())
-        .put(entry.localSeq, entry);
+    final PreparedEntry replaced =
+        realtimeEntriesByWriter
+            .computeIfAbsent(entry.writerNodeId, ignored -> new TreeMap<>())
+            .put(entry.localSeq, entry);
+    if (Objects.nonNull(replaced)) {
+      releaseTabletMemory(replaced.estimatedBytes);
+    }
   }
 
   private PreparedEntry peekRealtimeEntry(final int writerNodeId) {
@@ -1097,6 +1195,7 @@ public class ConsensusPrefetchingQueue {
                   .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_POLL_COMMITTED_EVENT_BROKEN_INVARIANT_E478FA3C,
               this,
               event);
+          cleanUpEvent(event, false);
           continue;
         }
 
@@ -1107,6 +1206,7 @@ public class ConsensusPrefetchingQueue {
               this,
               event);
           event.nack();
+          prefetchingQueue.add(event);
           continue;
         }
 
@@ -1190,6 +1290,7 @@ public class ConsensusPrefetchingQueue {
   // ======================== Prefetch Round Drive ========================
 
   private static final long WAL_GAP_RETRY_SLEEP_MS = 10L;
+  private static final long MEMORY_RETRY_SLEEP_MS = 100L;
   private static final long WAL_GAP_WAIT_LOG_INTERVAL_MS = 5_000L;
 
   private static final long PREFETCH_STATS_LOG_INTERVAL_MS = 5_000L;
@@ -1216,6 +1317,7 @@ public class ConsensusPrefetchingQueue {
       recycleInFlightEvents();
 
       if (!isActive || prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE) {
+        blockRealtimeAdmission();
         return computeIdleRoundResult();
       }
 
@@ -1225,6 +1327,28 @@ public class ConsensusPrefetchingQueue {
       final int maxTablets = config.getSubscriptionConsensusBatchMaxTabletCount();
       final long maxBatchBytes = config.getSubscriptionConsensusBatchMaxSizeInBytes();
 
+      // Always consume already materialized entries before converting more WAL requests into
+      // Tablets. Otherwise a full delivery batch can leave an unbounded hidden writer backlog.
+      if (!drainBufferedRealtimeWriters(
+          lingerBatch, observedSeekGeneration, maxTablets, maxBatchBytes)) {
+        resetRoundStateForSeek(seekGeneration.get());
+        return PrefetchRoundResult.rescheduleNow();
+      }
+      if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE
+          || !realtimeEntriesByWriter.isEmpty()) {
+        blockRealtimeAdmission();
+        return computeIdleRoundResult();
+      }
+      if (shouldWaitForSubscriptionMemory()) {
+        blockRealtimeAdmission();
+        if (!lingerBatch.isEmpty() && !flushBatch(lingerBatch, observedSeekGeneration)) {
+          resetRoundStateForSeek(seekGeneration.get());
+          return PrefetchRoundResult.rescheduleNow();
+        }
+        return PrefetchRoundResult.rescheduleAfter(MEMORY_RETRY_SLEEP_MS);
+      }
+      unblockRealtimeAdmission();
+
       final List<IndexedConsensusRequest> batch = drainPendingEntries(maxWalEntries);
       if (!batch.isEmpty()) {
         LOGGER.debug(
@@ -1237,22 +1361,38 @@ public class ConsensusPrefetchingQueue {
             nextExpectedSearchIndex.get(),
             prefetchingQueue.size());
 
-        final boolean batchAccepted =
+        final MaterializationResult batchResult =
             accumulateFromPending(
                 batch, lingerBatch, observedSeekGeneration, maxTablets, maxBatchBytes);
-        if (!batchAccepted) {
-          if (pendingWalGapRetryRequested) {
-            // Once a drained batch hits an unresolved WAL gap, the affected suffix falls back to
-            // the WAL path on later rounds instead of being requeued into the bounded pending path.
+        if (batchResult != MaterializationResult.SUCCESS) {
+          if (batchResult == MaterializationResult.WAL_GAP) {
             return PrefetchRoundResult.rescheduleAfter(WAL_GAP_RETRY_SLEEP_MS);
           }
+          if (batchResult == MaterializationResult.MEMORY_BLOCKED) {
+            // Publish already reserved Tablets immediately so ACK can release their memory. The
+            // blocked request and the drained suffix are recovered from WAL on a later round.
+            blockRealtimeAdmission();
+            if (!lingerBatch.isEmpty() && !flushBatch(lingerBatch, observedSeekGeneration)) {
+              resetRoundStateForSeek(seekGeneration.get());
+              return PrefetchRoundResult.rescheduleNow();
+            }
+            return PrefetchRoundResult.rescheduleAfter(MEMORY_RETRY_SLEEP_MS);
+          }
           resetRoundStateForSeek(seekGeneration.get());
           return PrefetchRoundResult.rescheduleNow();
         }
       }
 
       if (batch.isEmpty() && lingerBatch.isEmpty()) {
-        tryCatchUpFromWAL(observedSeekGeneration);
+        final MaterializationResult walResult = tryCatchUpFromWAL(observedSeekGeneration);
+        if (walResult == MaterializationResult.MEMORY_BLOCKED) {
+          blockRealtimeAdmission();
+          return PrefetchRoundResult.rescheduleAfter(MEMORY_RETRY_SLEEP_MS);
+        }
+        if (walResult == MaterializationResult.STALE) {
+          resetRoundStateForSeek(seekGeneration.get());
+          return PrefetchRoundResult.rescheduleNow();
+        }
       }
 
       if (!drainBufferedRealtimeWriters(
@@ -1290,6 +1430,7 @@ public class ConsensusPrefetchingQueue {
           fatal.getMessage(),
           fatal);
       if (fatal instanceof VirtualMachineError) {
+        closeCleanupPending = true;
         markClosed();
         return PrefetchRoundResult.dormant();
       }
@@ -1330,7 +1471,10 @@ public class ConsensusPrefetchingQueue {
 
   private void resetRoundStateForSeek(final long newSeekGeneration) {
     restorePendingSubscriptionWalCursor(newSeekGeneration);
-    lingerBatch.reset();
+    discardRealtimeEntries();
+    discardBatch(lingerBatch);
+    memoryBlockedEntryBytes = -1L;
+    realtimeAdmissionBlocked.set(false);
     resetBatchWriterProgress();
     observedSeekGeneration = newSeekGeneration;
   }
@@ -1338,7 +1482,7 @@ public class ConsensusPrefetchingQueue {
   private List<IndexedConsensusRequest> drainPendingEntries(final int maxWalEntries) {
     final List<IndexedConsensusRequest> batch = new ArrayList<>();
     IndexedConsensusRequest next;
-    while (batch.size() < maxWalEntries && (next = pendingEntries.poll()) != null) {
+    while (batch.size() < maxWalEntries && Objects.nonNull(next = pendingEntries.poll())) {
       batch.add(next);
     }
     return batch;
@@ -1349,6 +1493,7 @@ public class ConsensusPrefetchingQueue {
       return PrefetchRoundResult.dormant();
     }
     if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE) {
+      blockRealtimeAdmission();
       return PrefetchRoundResult.dormant();
     }
     if (hasImmediatePrefetchableWork()) {
@@ -1428,7 +1573,7 @@ public class ConsensusPrefetchingQueue {
     }
   }
 
-  private boolean appendRealtimeRequest(
+  private MaterializationResult appendRealtimeRequest(
       final IndexedConsensusRequest request,
       final DeliveryBatchState batchState,
       final long expectedSeekGeneration,
@@ -1437,27 +1582,29 @@ public class ConsensusPrefetchingQueue {
       final boolean fromPending) {
     final PreparedEntry preparedEntry = prepareEntry(request);
     if (Objects.isNull(preparedEntry)) {
-      return true;
+      return memoryBlockedEntryBytes > 0L
+          ? MaterializationResult.MEMORY_BLOCKED
+          : MaterializationResult.SUCCESS;
     }
     if (!appendPreparedEntryViaRealtimeWriter(
         batchState, preparedEntry, expectedSeekGeneration, maxTablets, maxBatchBytes)) {
-      return false;
+      return MaterializationResult.STALE;
     }
     if (fromPending) {
       markAcceptedFromPending();
     } else {
       markAcceptedFromWal();
     }
-    return true;
+    return MaterializationResult.SUCCESS;
   }
 
   /**
    * Accumulates tablets from pending entries into the linger buffer. When pending replay outruns
    * the local WAL reader, this method backfills the local-index gap from WAL before continuing.
    *
-   * @return false if the batch became stale because seek generation changed while flushing
+   * @return the materialization result for the drained pending batch
    */
-  private boolean accumulateFromPending(
+  private MaterializationResult accumulateFromPending(
       final List<IndexedConsensusRequest> batch,
       final DeliveryBatchState lingerBatch,
       final long expectedSeekGeneration,
@@ -1481,14 +1628,16 @@ public class ConsensusPrefetchingQueue {
             expected,
             searchIndex,
             searchIndex - expected);
-        if (!fillGapFromWAL(
-            expected,
-            searchIndex,
-            lingerBatch,
-            expectedSeekGeneration,
-            maxTablets,
-            maxBatchBytes)) {
-          return false;
+        final MaterializationResult gapFillResult =
+            fillGapFromWAL(
+                expected,
+                searchIndex,
+                lingerBatch,
+                expectedSeekGeneration,
+                maxTablets,
+                maxBatchBytes);
+        if (gapFillResult != MaterializationResult.SUCCESS) {
+          return gapFillResult;
         }
       }
 
@@ -1508,13 +1657,18 @@ public class ConsensusPrefetchingQueue {
         continue;
       }
 
-      if (!appendRealtimeRequest(
-          request, lingerBatch, expectedSeekGeneration, maxTablets, maxBatchBytes, true)) {
-        return false;
+      final MaterializationResult appendResult =
+          appendRealtimeRequest(
+              request, lingerBatch, expectedSeekGeneration, maxTablets, maxBatchBytes, true);
+      if (appendResult != MaterializationResult.SUCCESS) {
+        return appendResult;
       }
       markMaterializedProgress(request);
       processedCount++;
       advanceLocalCursorIfPresent(request);
+      if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE) {
+        break;
+      }
     }
 
     LOGGER.debug(
@@ -1527,7 +1681,7 @@ public class ConsensusPrefetchingQueue {
         lingerBatch.tablets.size(),
         nextExpectedSearchIndex.get());
 
-    return true;
+    return MaterializationResult.SUCCESS;
   }
 
   /**
@@ -1539,10 +1693,9 @@ public class ConsensusPrefetchingQueue {
    * affected suffix fall back to the WAL path on later rounds. This keeps replay contiguous without
    * requeueing the drained batch back into the bounded pending queue.
    *
-   * @return false if gap fill had to stop because the current batch became stale or the queue was
-   *     interrupted/closed
+   * @return the materialization result for the WAL gap
    */
-  private boolean fillGapFromWAL(
+  private MaterializationResult fillGapFromWAL(
       final long fromIndex,
       final long toIndex,
       final DeliveryBatchState batchState,
@@ -1552,18 +1705,20 @@ public class ConsensusPrefetchingQueue {
     pendingWalGapRetryRequested = false;
     resetSubscriptionWALPosition(fromIndex);
     if (seekGeneration.get() != expectedSeekGeneration || isClosed) {
-      return false;
+      return MaterializationResult.STALE;
     }
-    if (!pumpFromSubscriptionWAL(
-        batchState, expectedSeekGeneration, Integer.MAX_VALUE, maxTablets, maxBatchBytes)) {
-      return false;
+    final MaterializationResult pumpResult =
+        pumpFromSubscriptionWAL(
+            batchState, expectedSeekGeneration, Integer.MAX_VALUE, maxTablets, maxBatchBytes);
+    if (pumpResult != MaterializationResult.SUCCESS) {
+      return pumpResult;
     }
 
     final long nextExpected = nextExpectedSearchIndex.get();
     if (nextExpected >= toIndex) {
       walGapWaitStartTimeMs = 0L;
       lastWalGapWaitLogTimeMs = 0L;
-      return true;
+      return MaterializationResult.SUCCESS;
     }
 
     final long nowMs = System.currentTimeMillis();
@@ -1586,41 +1741,53 @@ public class ConsensusPrefetchingQueue {
     }
     onWalGapRetryScheduled();
     pendingWalGapRetryRequested = true;
-    return false;
+    return MaterializationResult.WAL_GAP;
   }
 
   /**
    * Try catch-up from WAL when the pending queue was empty. This handles cold-start or scenarios
    * where the subscription started after data was already written.
    */
-  private void tryCatchUpFromWAL(final long expectedSeekGeneration) {
+  private MaterializationResult tryCatchUpFromWAL(final long expectedSeekGeneration) {
     final SubscriptionConfig config = SubscriptionConfig.getInstance();
     final int maxTablets = config.getSubscriptionConsensusBatchMaxTabletCount();
     final long maxBatchBytes = config.getSubscriptionConsensusBatchMaxSizeInBytes();
     final int maxWalEntries = config.getSubscriptionConsensusBatchMaxWalEntries();
 
-    final DeliveryBatchState batchState = new DeliveryBatchState();
+    // Use the persistent linger batch so an unexpected runtime failure cannot orphan already
+    // reserved Tablets or advance replay progress past data that has become unreachable.
+    final DeliveryBatchState batchState = lingerBatch;
     resetSubscriptionWALPosition(nextExpectedSearchIndex.get());
-    final boolean accepted =
+    final MaterializationResult materializationResult =
         pumpFromSubscriptionWAL(
             batchState, expectedSeekGeneration, maxWalEntries, maxTablets, maxBatchBytes);
-    if (!accepted) {
-      return;
+    if (materializationResult != MaterializationResult.SUCCESS) {
+      if (materializationResult == MaterializationResult.MEMORY_BLOCKED && !batchState.isEmpty()) {
+        if (!flushBatch(batchState, expectedSeekGeneration)) {
+          discardBatch(batchState);
+          return MaterializationResult.STALE;
+        }
+      } else {
+        discardBatch(batchState);
+      }
+      return materializationResult;
     }
 
-    if (!batchState.isEmpty()) {
-      flushBatch(batchState, expectedSeekGeneration);
+    if (!batchState.isEmpty() && !flushBatch(batchState, expectedSeekGeneration)) {
+      discardBatch(batchState);
+      return MaterializationResult.STALE;
     }
+    return MaterializationResult.SUCCESS;
   }
 
-  private boolean pumpFromSubscriptionWAL(
+  private MaterializationResult pumpFromSubscriptionWAL(
       final DeliveryBatchState batchState,
       final long expectedSeekGeneration,
       final int maxWalEntries,
       final int maxTablets,
       final long maxBatchBytes) {
     if (Objects.isNull(subscriptionWALIterator)) {
-      return true;
+      return MaterializationResult.SUCCESS;
     }
 
     subscriptionWALIterator.refresh();
@@ -1646,9 +1813,14 @@ public class ConsensusPrefetchingQueue {
           continue;
         }
 
-        if (!appendRealtimeRequest(
-            walEntry, batchState, expectedSeekGeneration, maxTablets, maxBatchBytes, false)) {
-          return false;
+        final MaterializationResult appendResult =
+            appendRealtimeRequest(
+                walEntry, batchState, expectedSeekGeneration, maxTablets, maxBatchBytes, false);
+        if (appendResult != MaterializationResult.SUCCESS) {
+          if (appendResult == MaterializationResult.MEMORY_BLOCKED) {
+            resetSubscriptionWALPosition(nextExpectedSearchIndex.get());
+          }
+          return appendResult;
         }
         markMaterializedProgress(walEntry);
         advanceLocalCursorIfPresent(walEntry);
@@ -1670,7 +1842,7 @@ public class ConsensusPrefetchingQueue {
           entriesRead,
           nextExpectedSearchIndex.get());
     }
-    return true;
+    return MaterializationResult.SUCCESS;
   }
 
   private void ensureSubscriptionWalReadable() {
@@ -1764,6 +1936,7 @@ public class ConsensusPrefetchingQueue {
     final InsertNode insertNode =
         ConsensusLogToTabletConverter.deserializeToInsertNode(indexedRequest);
     if (Objects.isNull(insertNode)) {
+      memoryBlockedEntryBytes = -1L;
       return null;
     }
 
@@ -1785,24 +1958,24 @@ public class ConsensusPrefetchingQueue {
       maxObservedTimestamp = maxTs;
     }
     final List<Tablet> tablets = converter.convert(insertNode);
-    return new PreparedEntry(tablets, physicalTime, writerNodeId, localSeq, searchIndex);
+    final long estimatedBytes = estimateTabletsBytes(tablets);
+    if (!tryReserveTabletMemory(estimatedBytes)) {
+      return null;
+    }
+    return new PreparedEntry(
+        tablets, estimatedBytes, physicalTime, writerNodeId, localSeq, searchIndex);
   }
 
   private static long estimateTabletSize(final Tablet tablet) {
     return PipeMemoryWeightUtil.calculateTabletSizeInBytes(tablet);
   }
 
-  private void createAndEnqueueEvent(
-      final List<Tablet> tablets, final long startSearchIndex, final long endSearchIndex) {
-    createAndEnqueueEvent(
-        tablets, startSearchIndex, endSearchIndex, endSearchIndex, seekGeneration.get());
-  }
-
   private boolean createAndEnqueueEvent(
       final List<Tablet> tablets,
       final long startSearchIndex,
       final long endSearchIndex,
       final long commitLocalSeq,
+      final long retainedBytes,
       final long expectedSeekGeneration) {
     if (seekGeneration.get() != expectedSeekGeneration) {
       LOGGER.debug(
@@ -1841,6 +2014,8 @@ public class ConsensusPrefetchingQueue {
             SubscriptionAgent.broker().getColumnFilterMatcher(topicName).isTimeSelected(),
             getTimeSelectedByTable(converter.getDatabaseName(), tablets));
 
+    // Install the ownership record before exposing the event to concurrent poll/ack threads.
+    retainEventMemory(event, retainedBytes);
     prefetchingQueue.add(event);
 
     LOGGER.debug(
@@ -1914,6 +2089,173 @@ public class ConsensusPrefetchingQueue {
     return estimatedBytes;
   }
 
+  private boolean tryReserveTabletMemory(final long bytes) {
+    if (bytes <= 0L) {
+      memoryBlockedEntryBytes = -1L;
+      return true;
+    }
+    if (!subscriptionMemoryManager.tryAllocate(bytes)) {
+      memoryBlockedEntryBytes = bytes;
+      return false;
+    }
+    memoryBlockedEntryBytes = -1L;
+    retainedTabletBytes.addAndGet(bytes);
+    return true;
+  }
+
+  private boolean shouldWaitForSubscriptionMemory() {
+    final long blockedEntryBytes = memoryBlockedEntryBytes;
+    if (blockedEntryBytes <= 0L) {
+      return subscriptionMemoryManager.getFreeMemorySizeInBytes() <= 0L;
+    }
+
+    final long totalMemorySizeInBytes = subscriptionMemoryManager.getTotalMemorySizeInBytes();
+    final long usedMemorySizeInBytes = subscriptionMemoryManager.getUsedMemorySizeInBytes();
+    final boolean canAllocateWithinLimit =
+        blockedEntryBytes <= totalMemorySizeInBytes
+            && totalMemorySizeInBytes - usedMemorySizeInBytes >= blockedEntryBytes;
+    final boolean canAllocateSingleOversizedEntry =
+        totalMemorySizeInBytes > 0L
+            && usedMemorySizeInBytes == 0L
+            && blockedEntryBytes > totalMemorySizeInBytes;
+    if (canAllocateWithinLimit || canAllocateSingleOversizedEntry) {
+      memoryBlockedEntryBytes = -1L;
+      return false;
+    }
+    return true;
+  }
+
+  private void releaseTabletMemory(final long bytes) {
+    if (bytes <= 0L) {
+      return;
+    }
+    long currentRetainedBytes;
+    long releasedBytes;
+    do {
+      currentRetainedBytes = retainedTabletBytes.get();
+      if (currentRetainedBytes <= 0L) {
+        return;
+      }
+      releasedBytes = Math.min(bytes, currentRetainedBytes);
+    } while (!retainedTabletBytes.compareAndSet(
+        currentRetainedBytes, currentRetainedBytes - releasedBytes));
+    subscriptionMemoryManager.release(releasedBytes);
+  }
+
+  private void reconcileRetainedTabletMemoryAfterCleanup() {
+    // All indexed lifecycle owners have already been cleared. Any remaining bytes were reserved
+    // before a fatal failure interrupted the transfer into a tracked container.
+    releaseTabletMemory(retainedTabletBytes.get());
+  }
+
+  private void retainEventMemory(final SubscriptionEvent event, final long bytes) {
+    outstandingEventsByCommitContext.put(event.getCommitContext(), event);
+    if (bytes > 0L) {
+      retainedBytesByEvent.put(event, bytes);
+    }
+  }
+
+  private void cleanUpEvent(final SubscriptionEvent event, final boolean force) {
+    synchronized (event) {
+      final boolean indexed =
+          outstandingEventsByCommitContext.remove(event.getCommitContext(), event);
+      final Long retainedBytes = retainedBytesByEvent.remove(event);
+      // A materialized data event can be observed by a regular ACK and a late ACK concurrently.
+      // Only the thread that still owns either lifecycle index may clean and release it.
+      if (!indexed
+          && Objects.isNull(retainedBytes)
+          && event.getCommitContext().hasWriterProgress()) {
+        return;
+      }
+      try {
+        event.cleanUp(force);
+      } finally {
+        if (Objects.nonNull(retainedBytes)) {
+          releaseTabletMemory(retainedBytes);
+        }
+      }
+    }
+  }
+
+  private boolean ackMissingInFlightEvent(
+      final SubscriptionCommitContext commitContext, final boolean silent) {
+    acquireWriteLock();
+    try {
+      if (!canAcceptCommitContext(commitContext, "ack", silent)) {
+        return false;
+      }
+
+      final SubscriptionEvent event = outstandingEventsByCommitContext.get(commitContext);
+      // A recycled event may already have been claimed by another consumer. Only an event that is
+      // still queued can be committed by the previous consumer's late ACK.
+      if (Objects.nonNull(event) && !prefetchingQueue.remove(event)) {
+        return false;
+      }
+
+      final WriterId commitWriterId = extractCommitWriterId(commitContext);
+      final WriterProgress commitWriterProgress = extractCommitWriterProgress(commitContext);
+      boolean committed = false;
+      try {
+        committed =
+            commitManager.commitWithoutOutstanding(
+                consumerGroupId, topicName, consensusGroupId, commitWriterId, commitWriterProgress);
+      } finally {
+        if (!committed
+            && Objects.nonNull(event)
+            && outstandingEventsByCommitContext.get(commitContext) == event
+            && !event.isCommitted()) {
+          prefetchingQueue.add(event);
+        }
+      }
+
+      if (!committed) {
+        if (!silent) {
+          LOGGER.warn(
+              DataNodePipeMessages
+                  .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_COMMIT_CONTEXT_DOES_NOT_EXIST_99B8A8F3,
+              this,
+              commitContext);
+        }
+        return false;
+      }
+
+      if (Objects.nonNull(event)) {
+        event.ack();
+        event.recordCommittedTimestamp();
+        cleanUpEvent(event, false);
+      }
+      return true;
+    } finally {
+      releaseWriteLock();
+    }
+  }
+
+  private void discardBatch(final DeliveryBatchState batchState) {
+    releaseTabletMemory(batchState.estimatedBytes);
+    batchState.reset();
+  }
+
+  private void discardRealtimeEntries() {
+    long retainedBytes = 0L;
+    for (final NavigableMap<Long, PreparedEntry> writerEntries : realtimeEntriesByWriter.values()) {
+      for (final PreparedEntry entry : writerEntries.values()) {
+        retainedBytes += entry.estimatedBytes;
+      }
+    }
+    realtimeEntriesByWriter.clear();
+    releaseTabletMemory(retainedBytes);
+  }
+
+  private void discardRetainedEventMemory() {
+    long retainedBytes = 0L;
+    for (final Long eventRetainedBytes : retainedBytesByEvent.values()) {
+      retainedBytes += eventRetainedBytes;
+    }
+    retainedBytesByEvent.clear();
+    outstandingEventsByCommitContext.clear();
+    releaseTabletMemory(retainedBytes);
+  }
+
   private boolean appendPreparedEntryViaRealtimeWriter(
       final DeliveryBatchState batchState,
       final PreparedEntry preparedEntry,
@@ -1921,7 +2263,8 @@ public class ConsensusPrefetchingQueue {
       final int maxTablets,
       final long maxBatchBytes) {
     bufferRealtimeEntry(preparedEntry);
-    return drainRealtimeWriters(batchState, expectedSeekGeneration, maxTablets, maxBatchBytes);
+    return drainBufferedRealtimeWriters(
+        batchState, expectedSeekGeneration, maxTablets, maxBatchBytes);
   }
 
   private int getRealtimeBufferedEntryCount() {
@@ -2036,7 +2379,7 @@ public class ConsensusPrefetchingQueue {
         continue;
       }
 
-      final long entryEstimatedBytes = estimateTabletsBytes(writerHead.getTablets());
+      final long entryEstimatedBytes = writerHead.getEstimatedBytes();
       if (!canAppendWriterEntry(
           batchState, writerHead, entryEstimatedBytes, maxEntries, maxTablets, maxBatchBytes)) {
         return true;
@@ -2062,6 +2405,7 @@ public class ConsensusPrefetchingQueue {
             entry.getSearchIndex(),
             entry.getSearchIndex(),
             entry.getLocalSeq(),
+            0L,
             expectedSeekGeneration);
     resetBatchWriterProgress();
     return committed;
@@ -2075,6 +2419,7 @@ public class ConsensusPrefetchingQueue {
         batchState.startSearchIndex,
         batchState.endSearchIndex,
         batchState.lastLocalSeq,
+        batchState.estimatedBytes,
         expectedSeekGeneration)) {
       return false;
     }
@@ -2111,7 +2456,7 @@ public class ConsensusPrefetchingQueue {
             return null;
           }
           if (ev.isCommitted()) {
-            ev.cleanUp(false);
+            cleanUpEvent(ev, false);
             return null;
           }
           ev.recordLastPolledTimestamp();
@@ -2144,6 +2489,9 @@ public class ConsensusPrefetchingQueue {
       }
       return false;
     }
+    if (isCommitContextOutdated(commitContext)) {
+      return false;
+    }
     if (!isActive) {
       if (silent) {
         LOGGER.debug(
@@ -2168,49 +2516,51 @@ public class ConsensusPrefetchingQueue {
   }
 
   public boolean ack(final String consumerId, final SubscriptionCommitContext commitContext) {
+    InFlightAckResult result = InFlightAckResult.REJECTED;
     acquireReadLock();
     try {
-      return canAcceptCommitContext(commitContext, "ack", false)
-          && ackInternal(consumerId, commitContext);
+      if (canAcceptCommitContext(commitContext, "ack", false)) {
+        result = ackInFlightEvent(consumerId, commitContext, false);
+      }
     } finally {
       releaseReadLock();
     }
+
+    final boolean acked =
+        result == InFlightAckResult.ACKED
+            || (result == InFlightAckResult.MISSING
+                && ackMissingInFlightEvent(commitContext, false));
+    if (acked) {
+      requestPrefetch();
+    }
+    return acked;
   }
 
-  private boolean ackInternal(
-      final String consumerId, final SubscriptionCommitContext commitContext) {
+  private InFlightAckResult ackInFlightEvent(
+      final String consumerId,
+      final SubscriptionCommitContext commitContext,
+      final boolean silent) {
     final WriterId commitWriterId = extractCommitWriterId(commitContext);
     final WriterProgress commitWriterProgress = extractCommitWriterProgress(commitContext);
+    final AtomicBoolean found = new AtomicBoolean(false);
     final AtomicBoolean acked = new AtomicBoolean(false);
     inFlightEvents.compute(
         new InFlightEventKey(consumerId, commitContext),
         (key, ev) -> {
           if (Objects.isNull(ev)) {
-            final boolean directCommitted =
-                commitManager.commitWithoutOutstanding(
-                    consumerGroupId,
-                    topicName,
-                    consensusGroupId,
-                    commitWriterId,
-                    commitWriterProgress);
-            acked.set(directCommitted);
-            if (!acked.get()) {
+            return null;
+          }
+          found.set(true);
+
+          if (ev.isCommitted()) {
+            if (!silent) {
               LOGGER.warn(
                   DataNodePipeMessages
-                      .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_COMMIT_CONTEXT_DOES_NOT_EXIST_99B8A8F3,
+                      .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_EVENT_ALREADY_COMMITTED_AC34E829,
                   this,
                   commitContext);
             }
-            return null;
-          }
-
-          if (ev.isCommitted()) {
-            LOGGER.warn(
-                DataNodePipeMessages
-                    .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_EVENT_ALREADY_COMMITTED_AC34E829,
-                this,
-                commitContext);
-            ev.cleanUp(false);
+            cleanUpEvent(ev, false);
             return null;
           }
 
@@ -2222,22 +2572,25 @@ public class ConsensusPrefetchingQueue {
                   commitWriterId,
                   commitWriterProgress);
           if (!committed) {
-            LOGGER.warn(
-                DataNodePipeMessages
-                    .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_FAILED_TO_ADVANCE_COMMIT_FRONTIER_56E606C0,
-                this,
-                commitContext);
+            if (!silent) {
+              LOGGER.warn(
+                  DataNodePipeMessages
+                      .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_FAILED_TO_ADVANCE_COMMIT_FRONTIER_56E606C0,
+                  this,
+                  commitContext);
+            }
             return ev;
           }
 
           ev.ack();
           ev.recordCommittedTimestamp();
           acked.set(true);
-          ev.cleanUp(false);
+          cleanUpEvent(ev, false);
           return null;
         });
-
-    return acked.get();
+    return !found.get()
+        ? InFlightAckResult.MISSING
+        : acked.get() ? InFlightAckResult.ACKED : InFlightAckResult.REJECTED;
   }
 
   public boolean nack(final String consumerId, final SubscriptionCommitContext commitContext) {
@@ -2255,52 +2608,24 @@ public class ConsensusPrefetchingQueue {
    * in multi-region iteration where only one queue owns the event.
    */
   public boolean ackSilent(final String consumerId, final SubscriptionCommitContext commitContext) {
+    InFlightAckResult result = InFlightAckResult.REJECTED;
     acquireReadLock();
     try {
-      if (!canAcceptCommitContext(commitContext, "ack", true)) {
-        return false;
+      if (canAcceptCommitContext(commitContext, "ack", true)) {
+        result = ackInFlightEvent(consumerId, commitContext, true);
       }
-      final WriterId commitWriterId = extractCommitWriterId(commitContext);
-      final WriterProgress commitWriterProgress = extractCommitWriterProgress(commitContext);
-      final AtomicBoolean acked = new AtomicBoolean(false);
-      inFlightEvents.compute(
-          new InFlightEventKey(consumerId, commitContext),
-          (key, ev) -> {
-            if (Objects.isNull(ev)) {
-              final boolean directCommitted =
-                  commitManager.commitWithoutOutstanding(
-                      consumerGroupId,
-                      topicName,
-                      consensusGroupId,
-                      commitWriterId,
-                      commitWriterProgress);
-              acked.set(directCommitted);
-              return null;
-            }
-            if (ev.isCommitted()) {
-              ev.cleanUp(false);
-              return null;
-            }
-            final boolean committed =
-                commitManager.commit(
-                    consumerGroupId,
-                    topicName,
-                    consensusGroupId,
-                    commitWriterId,
-                    commitWriterProgress);
-            if (!committed) {
-              return ev;
-            }
-            ev.ack();
-            ev.recordCommittedTimestamp();
-            acked.set(true);
-            ev.cleanUp(false);
-            return null;
-          });
-      return acked.get();
     } finally {
       releaseReadLock();
     }
+
+    final boolean ackedResult =
+        result == InFlightAckResult.ACKED
+            || (result == InFlightAckResult.MISSING
+                && ackMissingInFlightEvent(commitContext, true));
+    if (ackedResult) {
+      requestPrefetch();
+    }
+    return ackedResult;
   }
 
   private WriterId extractCommitWriterId(final SubscriptionCommitContext commitContext) {
@@ -2342,7 +2667,7 @@ public class ConsensusPrefetchingQueue {
                   ev);
               ev.ack();
               ev.recordCommittedTimestamp();
-              ev.cleanUp(false);
+              cleanUpEvent(ev, false);
               return null;
             }
             prefetchingQueue.add(ev);
@@ -2380,7 +2705,7 @@ public class ConsensusPrefetchingQueue {
                 ev);
             ev.ack();
             ev.recordCommittedTimestamp();
-            ev.cleanUp(false);
+            cleanUpEvent(ev, false);
             return null;
           }
           prefetchingQueue.add(ev);
@@ -2402,7 +2727,7 @@ public class ConsensusPrefetchingQueue {
               return null;
             }
             if (ev.isCommitted()) {
-              ev.cleanUp(false);
+              cleanUpEvent(ev, false);
               return null;
             }
             if (ev.pollable()) {
@@ -2416,7 +2741,7 @@ public class ConsensusPrefetchingQueue {
                     ev);
                 ev.ack();
                 ev.recordCommittedTimestamp();
-                ev.cleanUp(false);
+                cleanUpEvent(ev, false);
                 return null;
               }
               prefetchingQueue.add(ev);
@@ -2435,20 +2760,26 @@ public class ConsensusPrefetchingQueue {
   // ======================== Cleanup ========================
 
   public void cleanUp() {
+    blockRealtimeAdmission();
     acquireWriteLock();
     try {
-      prefetchingQueue.forEach(event -> event.cleanUp(true));
+      prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
       prefetchingQueue.clear();
 
-      inFlightEvents.values().forEach(event -> event.cleanUp(true));
+      inFlightEvents.values().forEach(event -> cleanUpEvent(event, true));
       inFlightEvents.clear();
+      discardRetainedEventMemory();
 
-      realtimeEntriesByWriter.clear();
+      discardRealtimeEntries();
       writerStates.clear();
       clearRecoveryWriterProgress();
       materializedProgressByWriter.clear();
-      pendingEntries.clear();
-      lingerBatch.reset();
+      // A prefetch round that already held the read lock may have reopened admission while this
+      // cleanup was waiting for the write lock. Fence and clear once more under the write lock.
+      blockRealtimeAdmission();
+      discardBatch(lingerBatch);
+      reconcileRetainedTabletMemoryAfterCleanup();
+      memoryBlockedEntryBytes = -1L;
       resetBatchWriterProgress();
       pendingWalGapRetryRequested = false;
       walGapWaitStartTimeMs = 0L;
@@ -2714,17 +3045,21 @@ public class ConsensusPrefetchingQueue {
   }
 
   private void applySeekResetUnderWriteLock(final PendingSeekRequest request) {
+    blockRealtimeAdmission();
+
     // 1. Clean up all queued and in-flight events
-    prefetchingQueue.forEach(event -> event.cleanUp(true));
+    prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
     prefetchingQueue.clear();
-    inFlightEvents.values().forEach(event -> event.cleanUp(true));
+    inFlightEvents.values().forEach(event -> cleanUpEvent(event, true));
     inFlightEvents.clear();
+    discardRetainedEventMemory();
 
     // 2. Discard stale pending entries from in-memory queue
-    pendingEntries.clear();
+    memoryBlockedEntryBytes = -1L;
+    unblockRealtimeAdmission();
 
     // 3. Reset per-writer release state and source-level dedup frontiers.
-    realtimeEntriesByWriter.clear();
+    discardRealtimeEntries();
     writerStates.clear();
     clearRecoveryWriterProgress();
     materializedProgressByWriter.clear();
@@ -2738,7 +3073,8 @@ public class ConsensusPrefetchingQueue {
     // first replayable searchIndex here.
     nextExpectedSearchIndex.set(request.targetSearchIndex);
     requestSubscriptionWalReset(request.targetSearchIndex, seekGeneration.get());
-    lingerBatch.reset();
+    discardBatch(lingerBatch);
+    reconcileRetainedTabletMemoryAfterCleanup();
     resetBatchWriterProgress();
     observedSeekGeneration = seekGeneration.get();
     pendingWalGapRetryRequested = false;
@@ -2938,10 +3274,11 @@ public class ConsensusPrefetchingQueue {
 
     acquireWriteLock();
     try {
-      if (isClosed || closeRequested) {
+      if (closeRequested || (isClosed && !closeCleanupPending)) {
         return;
       }
       closeRequested = true;
+      closeCleanupPending = true;
       seekRequestToFail = pendingSeekRequest;
       pendingSeekRequest = null;
     } finally {
@@ -2999,6 +3336,7 @@ public class ConsensusPrefetchingQueue {
         // Persist progress before closing
         commitManager.persistAll();
       }
+      closeCleanupPending = false;
     } finally {
       closeRequested = false;
     }
@@ -3029,7 +3367,7 @@ public class ConsensusPrefetchingQueue {
           DataNodePipeMessages
               .PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_FAILED_TO_FLUSH_LINGERING_BATCH_F97D8AA7,
           this);
-      lingerBatch.reset();
+      discardBatch(lingerBatch);
       resetBatchWriterProgress();
     }
   }
@@ -3105,12 +3443,71 @@ public class ConsensusPrefetchingQueue {
    * active. Inactive queues skip prefetching and return null on poll.
    */
   public void setActive(final boolean active) {
-    this.isActive = active;
+    synchronized (runtimeActivationLock) {
+      setActiveUnderRuntimeLock(active);
+    }
+  }
+
+  private void setActiveUnderRuntimeLock(final boolean active) {
+    PendingSeekRequest seekRequestToFail = null;
+    if (this.isActive != active) {
+      if (active) {
+        this.isActive = true;
+        unblockRealtimeAdmission();
+      } else {
+        // Fence admission before waiting for the write lock so no new realtime reference can race
+        // with the lifecycle cleanup below.
+        this.isActive = false;
+        realtimeAdmissionBlocked.set(true);
+        acquireWriteLock();
+        try {
+          seekRequestToFail = pendingSeekRequest;
+          pendingSeekRequest = null;
+          seekGeneration.incrementAndGet();
+
+          prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
+          prefetchingQueue.clear();
+          inFlightEvents.values().forEach(event -> cleanUpEvent(event, true));
+          inFlightEvents.clear();
+          discardRetainedEventMemory();
+
+          pendingEntries.clear();
+          discardRealtimeEntries();
+          discardBatch(lingerBatch);
+          reconcileRetainedTabletMemoryAfterCleanup();
+          writerStates.clear();
+          clearRecoveryWriterProgress();
+          materializedProgressByWriter.clear();
+          resetBatchWriterProgress();
+
+          memoryBlockedEntryBytes = -1L;
+          prefetchInitialized = false;
+          observedSeekGeneration = seekGeneration.get();
+          pendingWalGapRetryRequested = false;
+          walGapWaitStartTimeMs = 0L;
+          lastWalGapWaitLogTimeMs = 0L;
+          pendingSubscriptionWalResetSearchIndex = Long.MIN_VALUE;
+          pendingSubscriptionWalResetGeneration = Long.MIN_VALUE;
+          closeSubscriptionWALIterator();
+        } finally {
+          releaseWriteLock();
+        }
+      }
+    }
     LOGGER.info(
         DataNodePipeMessages.PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_ISACTIVE_SET_TO_REGION_EC0AD7BA,
         this,
         active,
         consensusGroupId);
+    if (Objects.nonNull(seekRequestToFail)) {
+      seekRequestToFail.fail(
+          new IllegalStateException(
+              String.format(
+                  DataNodePipeMessages
+                      .PIPE_EXCEPTION_CONSENSUSPREFETCHINGQUEUE_S_RUNTIME_STOPPED_BEFORE_SEEK_7BCB4F4B,
+                  this,
+                  seekRequestToFail.seekReason)));
+    }
     if (active) {
       requestPrefetch();
     }
@@ -3259,6 +3656,14 @@ public class ConsensusPrefetchingQueue {
     return inFlightEvents.size();
   }
 
+  public long getRetainedTabletBytes() {
+    return retainedTabletBytes.get();
+  }
+
+  public long getSubscriptionMemoryLimitInBytes() {
+    return subscriptionMemoryManager.getTotalMemorySizeInBytes();
+  }
+
   /** Exposes the current seek generation for runtime tests and metrics. */
   public long getCurrentSeekGeneration() {
     return seekGeneration.get();
@@ -3323,6 +3728,12 @@ public class ConsensusPrefetchingQueue {
     result.put("prefetchingQueueSize", String.valueOf(prefetchingQueue.size()));
     result.put("inFlightEventsSize", String.valueOf(inFlightEvents.size()));
     result.put("pendingEntriesSize", String.valueOf(pendingEntries.size()));
+    result.put("retainedTabletBytes", String.valueOf(retainedTabletBytes.get()));
+    result.put("memoryBlockedEntryBytes", String.valueOf(memoryBlockedEntryBytes));
+    result.put("realtimeAdmissionBlocked", String.valueOf(realtimeAdmissionBlocked.get()));
+    result.put(
+        "subscriptionMemoryLimitInBytes",
+        String.valueOf(subscriptionMemoryManager.getTotalMemorySizeInBytes()));
     result.put("pendingPathAcceptedEntries", String.valueOf(getPendingPathAcceptedEntries()));
     result.put("walPathAcceptedEntries", String.valueOf(getWalPathAcceptedEntries()));
     result.put("seekGeneration", String.valueOf(seekGeneration.get()));
@@ -3380,6 +3791,8 @@ public class ConsensusPrefetchingQueue {
   private interface WriterBufferedEntry {
     List<Tablet> getTablets();
 
+    long getEstimatedBytes();
+
     long getPhysicalTime();
 
     int getWriterNodeId();
@@ -3459,6 +3872,7 @@ public class ConsensusPrefetchingQueue {
 
   private static final class PreparedEntry implements WriterBufferedEntry {
     private final List<Tablet> tablets;
+    private final long estimatedBytes;
     private final long physicalTime;
     private final int writerNodeId;
     private final long localSeq;
@@ -3466,11 +3880,13 @@ public class ConsensusPrefetchingQueue {
 
     private PreparedEntry(
         final List<Tablet> tablets,
+        final long estimatedBytes,
         final long physicalTime,
         final int writerNodeId,
         final long localSeq,
         final long searchIndex) {
       this.tablets = tablets;
+      this.estimatedBytes = estimatedBytes;
       this.physicalTime = physicalTime;
       this.writerNodeId = writerNodeId;
       this.localSeq = localSeq;
@@ -3482,6 +3898,11 @@ public class ConsensusPrefetchingQueue {
       return tablets;
     }
 
+    @Override
+    public long getEstimatedBytes() {
+      return estimatedBytes;
+    }
+
     @Override
     public long getPhysicalTime() {
       return physicalTime;
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionDataNodeResourceManager.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionDataNodeResourceManager.java
index 347299df10f..bf1a7de30f7 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionDataNodeResourceManager.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionDataNodeResourceManager.java
@@ -24,15 +24,21 @@ import org.apache.iotdb.db.subscription.resource.log.SubscriptionLogManager;
 public class SubscriptionDataNodeResourceManager {
 
   private final SubscriptionLogManager subscriptionLogManager;
+  private final SubscriptionMemoryManager subscriptionMemoryManager;
 
   public static SubscriptionLogManager log() {
     return SubscriptionDataNodeResourceManagerHolder.INSTANCE.subscriptionLogManager;
   }
 
+  public static SubscriptionMemoryManager memory() {
+    return SubscriptionDataNodeResourceManagerHolder.INSTANCE.subscriptionMemoryManager;
+  }
+
   ///////////////////////////// SINGLETON /////////////////////////////
 
   private SubscriptionDataNodeResourceManager() {
     subscriptionLogManager = new SubscriptionLogManager();
+    subscriptionMemoryManager = new SubscriptionMemoryManager();
   }
 
   private static class SubscriptionDataNodeResourceManagerHolder {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManager.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManager.java
new file mode 100644
index 00000000000..bf71fad6c4b
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManager.java
@@ -0,0 +1,89 @@
+/*
+ * 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.subscription.resource;
+
+import org.apache.iotdb.commons.memory.AtomicLongMemoryBlock;
+import org.apache.iotdb.commons.memory.IMemoryBlock;
+import org.apache.iotdb.commons.memory.MemoryBlockType;
+import org.apache.iotdb.commons.utils.TestOnly;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+
+public class SubscriptionMemoryManager {
+
+  private static final String MEMORY_BLOCK_NAME = "Subscription";
+
+  private final IMemoryBlock memoryBlock;
+
+  SubscriptionMemoryManager() {
+    memoryBlock =
+        IoTDBDescriptor.getInstance()
+            .getMemoryConfig()
+            .getSubscriptionMemoryManager()
+            .exactAllocate(MEMORY_BLOCK_NAME, MemoryBlockType.DYNAMIC);
+  }
+
+  @TestOnly
+  public SubscriptionMemoryManager(final long totalMemorySizeInBytes) {
+    memoryBlock =
+        new AtomicLongMemoryBlock(
+            MEMORY_BLOCK_NAME, null, totalMemorySizeInBytes, MemoryBlockType.DYNAMIC);
+  }
+
+  /**
+   * Reserves memory for materialized subscription data.
+   *
+   * <p>A single entry larger than the whole budget is allowed only while the budget is otherwise
+   * empty. This avoids permanently blocking progress while keeping the overrun bounded by one
+   * consensus entry.
+   */
+  public synchronized boolean tryAllocate(final long sizeInBytes) {
+    if (sizeInBytes <= 0L) {
+      return true;
+    }
+    if (memoryBlock.allocate(sizeInBytes)) {
+      return true;
+    }
+    if (memoryBlock.getTotalMemorySizeInBytes() > 0L
+        && memoryBlock.getUsedMemoryInBytes() == 0L
+        && sizeInBytes > memoryBlock.getTotalMemorySizeInBytes()) {
+      memoryBlock.forceAllocateWithoutLimitation(sizeInBytes);
+      return true;
+    }
+    return false;
+  }
+
+  public synchronized void release(final long sizeInBytes) {
+    if (sizeInBytes > 0L) {
+      memoryBlock.release(sizeInBytes);
+    }
+  }
+
+  public long getTotalMemorySizeInBytes() {
+    return memoryBlock.getTotalMemorySizeInBytes();
+  }
+
+  public long getUsedMemorySizeInBytes() {
+    return memoryBlock.getUsedMemoryInBytes();
+  }
+
+  public long getFreeMemorySizeInBytes() {
+    return memoryBlock.getFreeMemoryInBytes();
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/DataNodeMemoryConfigTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/DataNodeMemoryConfigTest.java
index 0d3447bbefb..54f4b849ce7 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/DataNodeMemoryConfigTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/conf/DataNodeMemoryConfigTest.java
@@ -20,12 +20,81 @@
 package org.apache.iotdb.db.conf;
 
 import org.apache.iotdb.commons.memory.MemoryConfig;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
 
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.util.Arrays;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
 public class DataNodeMemoryConfigTest {
 
+  @Test
+  public void testResolveSubscriptionQueryMemoryProportions() {
+    final int[] defaultProportions = DataNodeMemoryConfig.resolveQueryMemoryProportions(null, true);
+    assertArrayEquals(new int[] {1, 100, 200, 50, 200, 200, 200, 50, 250}, defaultProportions);
+    assertEquals(
+        0.2, (double) defaultProportions[8] / Arrays.stream(defaultProportions).sum(), 0.001);
+    assertArrayEquals(
+        new int[] {1, 100, 200, 50, 200, 200, 200, 50, 250},
+        DataNodeMemoryConfig.resolveQueryMemoryProportions("1:100:200:50:200:200:200:50", true));
+    assertArrayEquals(
+        new int[] {1, 100, 200, 50, 200, 200, 200, 50, 0},
+        DataNodeMemoryConfig.resolveQueryMemoryProportions(
+            "1:100:200:50:200:200:200:50:1000", false));
+  }
+
+  @Test
+  public void testRejectInvalidSubscriptionQueryMemoryProportions() {
+    final IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                DataNodeMemoryConfig.resolveQueryMemoryProportions(
+                    "1:100:200:50:200:200:200", true));
+    assertEquals(
+        String.format(
+            DataNodeMiscMessages
+                .EXCEPTION_QUERY_MEMORY_PROPORTIONS_MUST_CONTAIN_8_OR_9_COLON_SEPARATED_VALUES_BUT_FOUND_ARG_03A03941,
+            7),
+        exception.getMessage());
+  }
+
+  @Test
+  public void testRejectNegativeSubscriptionQueryMemoryProportion() {
+    final IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                DataNodeMemoryConfig.resolveQueryMemoryProportions(
+                    "1:100:200:50:200:200:200:50:-1", true));
+    assertEquals(
+        String.format(
+            DataNodeMiscMessages
+                .EXCEPTION_QUERY_MEMORY_PROPORTION_AT_POSITION_ARG_MUST_BE_NON_NEGATIVE_BUT_FOUND_ARG_DC69BC75,
+            9,
+            -1),
+        exception.getMessage());
+  }
+
+  @Test
+  public void testRejectNonPositiveSubscriptionQueryMemoryProportionSum() {
+    final IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> DataNodeMemoryConfig.resolveQueryMemoryProportions("0:0:0:0:0:0:0:0:0", true));
+    assertEquals(
+        String.format(
+            DataNodeMiscMessages
+                .EXCEPTION_THE_SUM_OF_QUERY_MEMORY_PROPORTIONS_MUST_BE_POSITIVE_BUT_WAS_ARG_407092B6,
+            0),
+        exception.getMessage());
+  }
+
   @Test
   public void testRpcMemoryControlIsActivatedOnlyExplicitly() {
     DataNodeMemoryConfig memoryConfig = IoTDBDescriptor.getInstance().getMemoryConfig();
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDataNodeMemoryTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDataNodeMemoryTest.java
new file mode 100644
index 00000000000..60f30834cad
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDataNodeMemoryTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.subscription.broker.consensus;
+
+import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.consensus.DataRegionId;
+import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
+import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
+import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
+import org.apache.iotdb.consensus.iot.WriterSafeFrontierTracker;
+import org.apache.iotdb.consensus.iot.log.ConsensusReqReader;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryWeightUtil;
+import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
+import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
+
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.Pair;
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ConsensusPrefetchingQueueDataNodeMemoryTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void testQueuesShareDataNodeMemoryBudget() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalBatchMaxDelay =
+        CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusBatchMaxDelayInMs();
+    final File systemDir = temporaryFolder.newFolder("shared-datanode-memory");
+    ConsensusPrefetchingQueue queueA = null;
+    ConsensusPrefetchingQueue queueB = null;
+    try {
+      CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxDelayInMs(0);
+      final Tablet tablet = createTablet();
+      final long oneTabletBytes = PipeMemoryWeightUtil.calculateTabletSizeInBytes(tablet);
+      final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(oneTabletBytes);
+      final ConsensusSubscriptionCommitManager commitManager = newCommitManager(systemDir);
+
+      final FakeConsensusReqReader readerA = new FakeConsensusReqReader();
+      final AtomicInteger conversionCountA = new AtomicInteger();
+      queueA =
+          newQueue(
+              "consumerGroupA",
+              new DataRegionId(1),
+              readerA,
+              newConverter(conversionCountA),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      queueA.setSubscriptionMemoryManager(memoryManager);
+
+      final FakeConsensusReqReader readerB = new FakeConsensusReqReader();
+      final AtomicInteger conversionCountB = new AtomicInteger();
+      queueB =
+          newQueue(
+              "consumerGroupB",
+              new DataRegionId(2),
+              readerB,
+              newConverter(conversionCountB),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      queueB.setSubscriptionMemoryManager(memoryManager);
+
+      assertNull(queueA.poll("consumerA"));
+      assertNull(queueB.poll("consumerB"));
+
+      readerA.currentSearchIndex = 1L;
+      assertTrue(pendingEntries(queueA).offer(createRequest(1L)));
+      queueA.drivePrefetchOnce();
+
+      assertEquals(1, conversionCountA.get());
+      assertEquals(0, conversionCountB.get());
+      assertEquals(oneTabletBytes, queueA.getRetainedTabletBytes());
+      assertEquals(0L, queueB.getRetainedTabletBytes());
+      assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+      assertTrue(
+          queueA.getRetainedTabletBytes() + queueB.getRetainedTabletBytes()
+              <= memoryManager.getTotalMemorySizeInBytes());
+
+      readerB.currentSearchIndex = 1L;
+      assertFalse(pendingEntries(queueB).offer(createRequest(1L)));
+      queueB.drivePrefetchOnce();
+      assertEquals(0, conversionCountB.get());
+      assertEquals("true", queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+
+      final SubscriptionEvent eventA = queueA.poll("consumerA");
+      assertNotNull(eventA);
+      assertTrue(queueA.ack("consumerA", eventA.getCommitContext()));
+      assertEquals(0L, queueA.getRetainedTabletBytes());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+
+      queueB.drivePrefetchOnce();
+      assertEquals("false", queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertTrue(pendingEntries(queueB).offer(createRequest(1L)));
+      queueB.drivePrefetchOnce();
+
+      assertEquals(1, conversionCountB.get());
+      assertEquals(oneTabletBytes, queueB.getRetainedTabletBytes());
+      assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+      assertTrue(
+          queueA.getRetainedTabletBytes() + queueB.getRetainedTabletBytes()
+              <= memoryManager.getTotalMemorySizeInBytes());
+
+      final SubscriptionEvent eventB = queueB.poll("consumerB");
+      assertNotNull(eventB);
+      assertTrue(queueB.ack("consumerB", eventB.getCommitContext()));
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queueA != null) {
+        queueA.close();
+      }
+      if (queueB != null) {
+        queueB.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testRealtimeBacklogIsDrainedBeforeMorePendingEntriesAreConverted() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalBatchMaxDelay =
+        CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusBatchMaxDelayInMs();
+    final File systemDir = temporaryFolder.newFolder("realtime-backlog-first");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxDelayInMs(60_000);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final AtomicInteger conversionCount = new AtomicInteger();
+      queue =
+          newQueue(
+              "consumerGroup",
+              new DataRegionId(1),
+              reader,
+              newConverter(conversionCount),
+              newCommitManager(systemDir),
+              TopicConstant.ORDER_MODE_MULTI_WRITER_VALUE);
+      queue.setSubscriptionMemoryManager(
+          new SubscriptionMemoryManager(
+              PipeMemoryWeightUtil.calculateTabletSizeInBytes(createTablet()) * 4L));
+      queue.setActiveWriterNodeIds(Set.of(7, 8));
+
+      assertNull(queue.poll("consumer"));
+      reader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+      queue.drivePrefetchOnce();
+
+      assertEquals(1, conversionCount.get());
+      assertEquals(2L, queue.getCurrentReadSearchIndex());
+      assertEquals("1", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals(0, queue.getPrefetchedEventCount());
+
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+      queue.drivePrefetchOnce();
+
+      assertEquals(1, conversionCount.get());
+      assertEquals(2L, queue.getCurrentReadSearchIndex());
+      assertEquals("1", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals("0", queue.coreReportMessage().get("pendingEntriesSize"));
+      assertEquals("true", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+
+      queue.setActiveWriterNodeIds(Collections.singleton(7));
+      queue.drivePrefetchOnce();
+
+      assertEquals(1, conversionCount.get());
+      assertEquals("0", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals("false", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+      queue.drivePrefetchOnce();
+
+      assertEquals(2, conversionCount.get());
+      assertEquals(3L, queue.getCurrentReadSearchIndex());
+      assertEquals("0", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals("0", queue.coreReportMessage().get("pendingEntriesSize"));
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  private static ConsensusPrefetchingQueue newQueue(
+      final String consumerGroupId,
+      final DataRegionId regionId,
+      final FakeConsensusReqReader reader,
+      final ConsensusLogToTabletConverter converter,
+      final ConsensusSubscriptionCommitManager commitManager,
+      final String orderMode) {
+    final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+    when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+    when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+    return new ConsensusPrefetchingQueue(
+        consumerGroupId,
+        "topic",
+        orderMode,
+        regionId,
+        serverImpl,
+        new SubscriptionWalRetentionPolicy(
+            "topic",
+            SubscriptionWalRetentionPolicy.UNBOUNDED,
+            SubscriptionWalRetentionPolicy.UNBOUNDED),
+        converter,
+        commitManager,
+        new RegionProgress(Collections.emptyMap()),
+        1L,
+        1L,
+        true);
+  }
+
+  private static ConsensusLogToTabletConverter newConverter(final AtomicInteger conversionCount) {
+    final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+    when(converter.convert(any()))
+        .thenAnswer(
+            ignored -> {
+              conversionCount.incrementAndGet();
+              return Collections.singletonList(createTablet());
+            });
+    when(converter.getDatabaseName()).thenReturn("db");
+    return converter;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static BlockingQueue<IndexedConsensusRequest> pendingEntries(
+      final ConsensusPrefetchingQueue queue) throws Exception {
+    final Field field = ConsensusPrefetchingQueue.class.getDeclaredField("pendingEntries");
+    field.setAccessible(true);
+    return (BlockingQueue<IndexedConsensusRequest>) field.get(queue);
+  }
+
+  private static Tablet createTablet() {
+    final List<String> columnNames = Arrays.asList("device", "temperature");
+    final List<TSDataType> dataTypes = Arrays.asList(TSDataType.STRING, TSDataType.DOUBLE);
+    final List<ColumnCategory> categories = Arrays.asList(ColumnCategory.TAG, ColumnCategory.FIELD);
+    final Tablet tablet = new Tablet("sensors", columnNames, dataTypes, categories, 1);
+    tablet.addTimestamp(0, 1L);
+    tablet.addValue(0, 0, "d1");
+    tablet.addValue(0, 1, 36.5);
+    tablet.setRowSize(1);
+    return tablet;
+  }
+
+  private static IndexedConsensusRequest createRequest(final long searchIndex) {
+    return new IndexedConsensusRequest(
+            searchIndex,
+            Collections.singletonList(
+                StatementTestUtils.genInsertRowNode(Math.toIntExact(searchIndex))))
+        .setPhysicalTime(1000L + searchIndex)
+        .setNodeId(7);
+  }
+
+  private static ConsensusSubscriptionCommitManager newCommitManager(final File systemDir)
+      throws Exception {
+    IoTDBDescriptor.getInstance().getConfig().setSystemDir(systemDir.getAbsolutePath());
+    final Constructor<ConsensusSubscriptionCommitManager> constructor =
+        ConsensusSubscriptionCommitManager.class.getDeclaredConstructor();
+    constructor.setAccessible(true);
+    return constructor.newInstance();
+  }
+
+  private static final class FakeConsensusReqReader implements ConsensusReqReader {
+
+    private long currentSearchIndex;
+
+    @Override
+    public void setSafelyDeletedSearchIndex(final long safelyDeletedSearchIndex) {
+      // no-op
+    }
+
+    @Override
+    public ReqIterator getReqIterator(final long startIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getCurrentSearchIndex() {
+      return currentSearchIndex;
+    }
+
+    @Override
+    public long getCurrentWALFileVersion() {
+      return 0;
+    }
+
+    @Override
+    public long getTotalSize() {
+      return 0;
+    }
+
+    @Override
+    public Pair<Long, Long> getDeletionBoundToFreeAtLeast(final long bytesToFree) {
+      return new Pair<>(DEFAULT_SAFELY_DELETED_SEARCH_INDEX, 0L);
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
index 1ad473cb564..8460fe319f8 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
@@ -29,6 +29,7 @@ import org.apache.iotdb.consensus.iot.log.ConsensusReqReader;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
 import org.apache.iotdb.rpc.subscription.payload.poll.WriterId;
@@ -45,10 +46,18 @@ import org.junit.rules.TemporaryFolder;
 import java.io.File;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -63,6 +72,107 @@ public class ConsensusPrefetchingQueueTest {
 
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+  @Test
+  public void testInitializationAndActivationUseIndependentMonitors() throws Exception {
+    assertFalse(
+        Modifier.isSynchronized(
+            ConsensusPrefetchingQueue.class
+                .getDeclaredMethod("initPrefetch", RegionProgress.class)
+                .getModifiers()));
+    assertFalse(
+        Modifier.isSynchronized(
+            ConsensusPrefetchingQueue.class
+                .getDeclaredMethod("setActive", boolean.class)
+                .getModifiers()));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testAdmissionClearCannotLeaveEntryEnqueuedAfterFence() throws Exception {
+    final Class<?> queueClass =
+        Class.forName(ConsensusPrefetchingQueue.class.getName() + "$WakeableIndexedConsensusQueue");
+    final Constructor<?> constructor =
+        queueClass.getDeclaredConstructor(int.class, Runnable.class, BooleanSupplier.class);
+    constructor.setAccessible(true);
+
+    final AtomicBoolean admissionEnabled = new AtomicBoolean(true);
+    final CountDownLatch admissionChecked = new CountDownLatch(1);
+    final CountDownLatch finishAdmissionCheck = new CountDownLatch(1);
+    final CountDownLatch clearInvoked = new CountDownLatch(1);
+    final CountDownLatch clearCompleted = new CountDownLatch(1);
+    final AtomicReference<Boolean> offered = new AtomicReference<>();
+    final AtomicReference<Throwable> asyncFailure = new AtomicReference<>();
+    final BooleanSupplier admissionSupplier =
+        () -> {
+          final boolean admitted = admissionEnabled.get();
+          admissionChecked.countDown();
+          try {
+            if (!finishAdmissionCheck.await(5, TimeUnit.SECONDS)) {
+              throw new AssertionError();
+            }
+          } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new AssertionError(e);
+          }
+          return admitted;
+        };
+    final BlockingQueue<IndexedConsensusRequest> queue =
+        (BlockingQueue<IndexedConsensusRequest>)
+            constructor.newInstance(8, (Runnable) () -> {}, admissionSupplier);
+
+    final Thread offerThread =
+        new Thread(
+            () -> {
+              try {
+                offered.set(queue.offer(createRequest(1L)));
+              } catch (final Throwable t) {
+                asyncFailure.compareAndSet(null, t);
+              }
+            });
+    final Thread clearThread =
+        new Thread(
+            () -> {
+              try {
+                clearInvoked.countDown();
+                queue.clear();
+              } catch (final Throwable t) {
+                asyncFailure.compareAndSet(null, t);
+              } finally {
+                clearCompleted.countDown();
+              }
+            });
+    offerThread.setDaemon(true);
+    clearThread.setDaemon(true);
+
+    offerThread.start();
+    try {
+      assertTrue(admissionChecked.await(5, TimeUnit.SECONDS));
+      admissionEnabled.set(false);
+      clearThread.start();
+      assertTrue(clearInvoked.await(5, TimeUnit.SECONDS));
+
+      final long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+      while (clearCompleted.getCount() > 0
+          && clearThread.getState() != Thread.State.BLOCKED
+          && System.nanoTime() < deadlineNanos) {
+        Thread.yield();
+      }
+      assertTrue(clearCompleted.getCount() == 0 || clearThread.getState() == Thread.State.BLOCKED);
+    } finally {
+      finishAdmissionCheck.countDown();
+      offerThread.join(TimeUnit.SECONDS.toMillis(5));
+      clearThread.join(TimeUnit.SECONDS.toMillis(5));
+    }
+
+    assertFalse(offerThread.isAlive());
+    assertFalse(clearThread.isAlive());
+    if (asyncFailure.get() != null) {
+      throw new AssertionError(asyncFailure.get());
+    }
+    assertTrue(Boolean.TRUE.equals(offered.get()));
+    assertTrue(queue.isEmpty());
+  }
+
   @Test
   public void testLagIncludesLingeringBatchUntilCommitted() throws Exception {
     final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
@@ -234,7 +344,7 @@ public class ConsensusPrefetchingQueueTest {
                   1L, Collections.singletonList(StatementTestUtils.genInsertRowNode(1)))
               .setPhysicalTime(1000L)
               .setNodeId(7);
-      final IndexedConsensusRequest emptyRequest =
+      final IndexedConsensusRequest requestWithEmptyConversionResult =
           new IndexedConsensusRequest(
                   2L, Collections.singletonList(StatementTestUtils.genInsertRowNode(2)))
               .setPhysicalTime(1001L)
@@ -243,7 +353,7 @@ public class ConsensusPrefetchingQueueTest {
 
       assertNull(queue.poll("consumer"));
       pendingEntries(queue).offer(dataRequest);
-      pendingEntries(queue).offer(emptyRequest);
+      pendingEntries(queue).offer(requestWithEmptyConversionResult);
       queue.drivePrefetchOnce();
 
       final WriterId writerId = new WriterId(regionId.toString(), 7);
@@ -272,6 +382,553 @@ public class ConsensusPrefetchingQueueTest {
     }
   }
 
+  @Test
+  public void testTabletMemoryReleasedAfterAck() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("system-memory-release");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenReturn(Collections.singletonList(createTablet()), Collections.emptyList());
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+
+      final IndexedConsensusRequest dataRequest =
+          new IndexedConsensusRequest(
+                  1L, Collections.singletonList(StatementTestUtils.genInsertRowNode(1)))
+              .setPhysicalTime(1000L)
+              .setNodeId(7);
+      final IndexedConsensusRequest requestWithEmptyConversionResult =
+          new IndexedConsensusRequest(
+                  2L, Collections.singletonList(StatementTestUtils.genInsertRowNode(2)))
+              .setPhysicalTime(1001L)
+              .setNodeId(7);
+      reader.currentSearchIndex = 2L;
+      pendingEntries(queue).offer(dataRequest);
+      pendingEntries(queue).offer(requestWithEmptyConversionResult);
+
+      assertNull(queue.poll("consumer"));
+      queue.drivePrefetchOnce();
+
+      final long retainedBytes = queue.getRetainedTabletBytes();
+      assertTrue(retainedBytes > 0L);
+      final SubscriptionEvent event = queue.poll("consumer");
+      assertNotNull(event);
+      assertEquals(retainedBytes, queue.getRetainedTabletBytes());
+
+      assertTrue(queue.ack("consumer", event.getCommitContext()));
+      assertEquals(0L, queue.getRetainedTabletBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testCleanupReconcilesUnindexedTabletReservation() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("system-orphan-memory-release");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              mock(ConsensusLogToTabletConverter.class),
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(1024L);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      final Method reserveTabletMemory =
+          ConsensusPrefetchingQueue.class.getDeclaredMethod("tryReserveTabletMemory", long.class);
+      reserveTabletMemory.setAccessible(true);
+      assertTrue((Boolean) reserveTabletMemory.invoke(queue, 256L));
+      assertEquals(256L, queue.getRetainedTabletBytes());
+      assertEquals(256L, memoryManager.getUsedMemorySizeInBytes());
+
+      queue.cleanUp();
+
+      assertEquals(0L, queue.getRetainedTabletBytes());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testMemoryBackpressureStopsFurtherMaterializationUntilAck() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("system-memory-backpressure");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final AtomicInteger conversionCount = new AtomicInteger();
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenAnswer(
+              ignored -> {
+                conversionCount.incrementAndGet();
+                return Collections.singletonList(createTablet());
+              });
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final long oneTabletBytes = createTablet().ramBytesUsed();
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(oneTabletBytes + Math.max(1L, oneTabletBytes / 2L));
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      final IndexedConsensusRequest firstRequest = createRequest(1L);
+      final IndexedConsensusRequest secondRequest = createRequest(2L);
+      final IndexedConsensusRequest thirdRequest = createRequest(3L);
+      assertTrue(pendingEntries(queue).offer(firstRequest));
+      assertTrue(pendingEntries(queue).offer(secondRequest));
+      assertTrue(pendingEntries(queue).offer(thirdRequest));
+      reader.currentSearchIndex = 3L;
+
+      assertNull(queue.poll("consumer"));
+      queue.drivePrefetchOnce();
+
+      assertEquals(2, conversionCount.get());
+      assertEquals(2L, queue.getCurrentReadSearchIndex());
+      assertEquals(oneTabletBytes, queue.getRetainedTabletBytes());
+      assertTrue(memoryManager.getFreeMemorySizeInBytes() > 0L);
+      assertEquals(1, queue.getPrefetchedEventCount());
+      assertTrue(pendingEntries(queue).isEmpty());
+      assertEquals("true", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertFalse(pendingEntries(queue).offer(createRequest(4L)));
+
+      queue.drivePrefetchOnce();
+      assertEquals(2, conversionCount.get());
+      assertEquals(oneTabletBytes, queue.getRetainedTabletBytes());
+
+      final SubscriptionEvent event = queue.poll("consumer");
+      assertNotNull(event);
+      assertTrue(queue.ack("consumer", event.getCommitContext()));
+      assertEquals(0L, queue.getRetainedTabletBytes());
+
+      queue.drivePrefetchOnce();
+      assertEquals("false", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertTrue(pendingEntries(queue).offer(secondRequest));
+      assertTrue(pendingEntries(queue).offer(thirdRequest));
+      queue.drivePrefetchOnce();
+      assertEquals(4, conversionCount.get());
+      assertEquals(3L, queue.getCurrentReadSearchIndex());
+      assertEquals(oneTabletBytes, queue.getRetainedTabletBytes());
+      assertEquals(1, queue.getPrefetchedEventCount());
+
+      final SubscriptionEvent secondEvent = queue.poll("consumer");
+      assertNotNull(secondEvent);
+      assertTrue(queue.ack("consumer", secondEvent.getCommitContext()));
+      queue.drivePrefetchOnce();
+      assertTrue(pendingEntries(queue).offer(thirdRequest));
+      queue.drivePrefetchOnce();
+
+      assertEquals(5, conversionCount.get());
+      assertEquals(4L, queue.getCurrentReadSearchIndex());
+      assertEquals("0", queue.coreReportMessage().get("pendingEntriesSize"));
+      assertEquals("0", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals(oneTabletBytes, queue.getRetainedTabletBytes());
+
+      queue.close();
+      queue = null;
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testWideTablePausedConsumerKeepsMaterializedMemoryBounded() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("system-wide-table-memory-bound");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final int columnCount = 128;
+      final int rowCount = 64;
+      final long oneTabletBytes = createWideTablet(columnCount, rowCount).ramBytesUsed();
+      final AtomicInteger conversionCount = new AtomicInteger();
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenAnswer(
+              ignored -> {
+                conversionCount.incrementAndGet();
+                return Collections.singletonList(createWideTablet(columnCount, rowCount));
+              });
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final long memoryLimit = oneTabletBytes * 2L + oneTabletBytes / 2L;
+      final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(memoryLimit);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      final int writeCount = 256;
+      // One scheduling attempt per submitted write proves repeated prefetch cannot bypass
+      // backpressure.
+      final int backpressureVerificationRounds = writeCount;
+      for (long searchIndex = 1L; searchIndex <= writeCount; searchIndex++) {
+        assertTrue(pendingEntries(queue).offer(createRequest(searchIndex)));
+      }
+      reader.currentSearchIndex = writeCount;
+
+      assertNull(queue.poll("pausedConsumer"));
+      queue.drivePrefetchOnce();
+
+      assertEquals(3, conversionCount.get());
+      assertEquals(3L, queue.getCurrentReadSearchIndex());
+      assertEquals(oneTabletBytes * 2L, queue.getRetainedTabletBytes());
+      assertTrue(queue.getRetainedTabletBytes() <= queue.getSubscriptionMemoryLimitInBytes());
+      assertEquals(1, queue.getPrefetchedEventCount());
+      assertEquals("0", queue.coreReportMessage().get("pendingEntriesSize"));
+      assertEquals("0", queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
+      assertEquals("true", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+
+      for (int round = 0; round < backpressureVerificationRounds; round++) {
+        queue.drivePrefetchOnce();
+      }
+      assertEquals(3, conversionCount.get());
+      assertEquals(oneTabletBytes * 2L, queue.getRetainedTabletBytes());
+      assertFalse(pendingEntries(queue).offer(createRequest(writeCount + 1L)));
+
+      final SubscriptionEvent pausedEvent = queue.poll("pausedConsumer");
+      assertNotNull(pausedEvent);
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+      for (int round = 0; round < backpressureVerificationRounds; round++) {
+        queue.drivePrefetchOnce();
+      }
+      assertEquals(3, conversionCount.get());
+      assertEquals(oneTabletBytes * 2L, queue.getRetainedTabletBytes());
+
+      queue.close();
+      queue = null;
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testLateAckAfterRecycleReleasesQueuedTabletMemory() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalRecycleInterval =
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .getSubscriptionRecycleUncommittedEventIntervalMs();
+    final File systemDir = temporaryFolder.newFolder("system-late-ack-memory-release");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(-1);
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenReturn(Collections.singletonList(createTablet()), Collections.emptyList());
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(createTablet().ramBytesUsed() * 2L);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      reader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+
+      assertNull(queue.poll("consumer"));
+      queue.drivePrefetchOnce();
+      final SubscriptionEvent event = queue.poll("consumer");
+      assertNotNull(event);
+      assertTrue(queue.getRetainedTabletBytes() > 0L);
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+
+      queue.drivePrefetchOnce();
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(1, queue.getPrefetchedEventCount());
+
+      assertTrue(queue.ack("consumer", event.getCommitContext()));
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(0L, queue.getRetainedTabletBytes());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(originalRecycleInterval);
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testLateAckDoesNotStealRecycledEventFromNewConsumer() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalRecycleInterval =
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .getSubscriptionRecycleUncommittedEventIntervalMs();
+    final File systemDir = temporaryFolder.newFolder("system-late-ack-new-owner");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(-1);
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenReturn(Collections.singletonList(createTablet()), Collections.emptyList());
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(createTablet().ramBytesUsed() * 2L);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      reader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+
+      assertNull(queue.poll("oldConsumer"));
+      queue.drivePrefetchOnce();
+      final SubscriptionEvent event = queue.poll("oldConsumer");
+      assertNotNull(event);
+      final long retainedBytes = queue.getRetainedTabletBytes();
+      assertTrue(retainedBytes > 0L);
+
+      queue.drivePrefetchOnce();
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(1, queue.getPrefetchedEventCount());
+
+      final SubscriptionEvent redeliveredEvent = queue.poll("newConsumer");
+      assertTrue(redeliveredEvent == event);
+      assertFalse(queue.ack("oldConsumer", event.getCommitContext()));
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(retainedBytes, queue.getRetainedTabletBytes());
+      assertTrue(memoryManager.getUsedMemorySizeInBytes() > 0L);
+
+      assertTrue(queue.ack("newConsumer", event.getCommitContext()));
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(0L, queue.getRetainedTabletBytes());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(originalRecycleInterval);
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testDeactivationReleasesMaterializedTabletMemory() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("system-deactivation-memory-release");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(1);
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenReturn(Collections.singletonList(createTablet()), Collections.emptyList());
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(createTablet().ramBytesUsed() * 2L);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      reader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+
+      assertNull(queue.poll("consumer"));
+      queue.drivePrefetchOnce();
+      final SubscriptionEvent event = queue.poll("consumer");
+      assertNotNull(event);
+      assertTrue(queue.getRetainedTabletBytes() > 0L);
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+      final long previousSeekGeneration = queue.getCurrentSeekGeneration();
+
+      queue.setActive(false);
+
+      assertFalse(queue.isActive());
+      assertEquals(0L, queue.getInitializedStatus());
+      assertEquals(previousSeekGeneration + 1L, queue.getCurrentSeekGeneration());
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(0L, queue.getRetainedTabletBytes());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+      assertFalse(pendingEntries(queue).offer(createRequest(3L)));
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
   @SuppressWarnings("unchecked")
   private static BlockingQueue<IndexedConsensusRequest> pendingEntries(
       final ConsensusPrefetchingQueue queue) throws Exception {
@@ -292,6 +949,42 @@ public class ConsensusPrefetchingQueueTest {
     return tablet;
   }
 
+  private static Tablet createWideTablet(final int columnCount, final int rowCount) {
+    final String[] columnNames = new String[columnCount];
+    final TSDataType[] dataTypes = new TSDataType[columnCount];
+    final ColumnCategory[] categories = new ColumnCategory[columnCount];
+    Arrays.fill(dataTypes, TSDataType.DOUBLE);
+    Arrays.fill(categories, ColumnCategory.FIELD);
+    for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
+      columnNames[columnIndex] = "field_" + columnIndex;
+    }
+
+    final Tablet tablet =
+        new Tablet(
+            "wide_table",
+            Arrays.asList(columnNames),
+            Arrays.asList(dataTypes),
+            Arrays.asList(categories),
+            rowCount);
+    for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+      tablet.addTimestamp(rowIndex, rowIndex);
+      for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
+        tablet.addValue(rowIndex, columnIndex, (double) (rowIndex + columnIndex));
+      }
+    }
+    tablet.setRowSize(rowCount);
+    return tablet;
+  }
+
+  private static IndexedConsensusRequest createRequest(final long searchIndex) {
+    return new IndexedConsensusRequest(
+            searchIndex,
+            Collections.singletonList(
+                StatementTestUtils.genInsertRowNode(Math.toIntExact(searchIndex))))
+        .setPhysicalTime(1000L + searchIndex)
+        .setNodeId(7);
+  }
+
   private static ConsensusSubscriptionCommitManager newCommitManager(final File systemDir)
       throws Exception {
     IoTDBDescriptor.getInstance().getConfig().setSystemDir(systemDir.getAbsolutePath());
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
new file mode 100644
index 00000000000..b3783beab43
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
@@ -0,0 +1,336 @@
+/*
+ * 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.subscription.broker.consensus;
+
+import org.apache.iotdb.commons.conf.CommonConfig;
+import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.consensus.DataRegionId;
+import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
+import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
+import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
+import org.apache.iotdb.consensus.iot.WriterSafeFrontierTracker;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertNode;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.RelationalInsertRowNode;
+import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
+import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALInfoEntry;
+import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALFileVersion;
+import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALMetaData;
+import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALWriter;
+import org.apache.iotdb.db.storageengine.dataregion.wal.node.WALNode;
+import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALByteBufferForTest;
+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.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
+import org.apache.iotdb.rpc.subscription.payload.poll.WriterId;
+import org.apache.iotdb.rpc.subscription.payload.poll.WriterProgress;
+
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ConsensusPrefetchingQueueWalBackpressureTest {
+
+  private static final int WRITER_NODE_ID = 7;
+  private static final int REQUEST_COUNT = 3;
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void testAckRecoversDrainedSuffixFromWalWithoutReoffer() throws Exception {
+    final String originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final CommonConfig config = CommonDescriptor.getInstance().getConfig();
+    final int originalBatchMaxWalEntries = config.getSubscriptionConsensusBatchMaxWalEntries();
+    final int originalBatchMaxTabletCount = config.getSubscriptionConsensusBatchMaxTabletCount();
+    final long originalBatchMaxSize = config.getSubscriptionConsensusBatchMaxSizeInBytes();
+    final int originalBatchMaxDelay = config.getSubscriptionConsensusBatchMaxDelayInMs();
+    final File systemDir = temporaryFolder.newFolder("system-wal-backpressure-recovery");
+    final File walDirectory = temporaryFolder.newFolder("wal-backpressure-recovery");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final Tablet sampleTablet = createTablet();
+      final long oneTabletBytes = sampleTablet.ramBytesUsed();
+      config.setSubscriptionConsensusBatchMaxWalEntries(128);
+      config.setSubscriptionConsensusBatchMaxTabletCount(128);
+      config.setSubscriptionConsensusBatchMaxSizeInBytes(oneTabletBytes * 16L);
+      config.setSubscriptionConsensusBatchMaxDelayInMs(60_000);
+
+      writeSealedWal(walDirectory);
+
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getLogDirectory()).thenReturn(walDirectory);
+      when(walNode.getCurrentSearchIndex()).thenReturn((long) REQUEST_COUNT);
+      when(walNode.getCurrentWALFileVersion()).thenReturn(1L);
+      when(walNode.getCurrentWALMetaDataSnapshot()).thenReturn(new WALMetaData());
+
+      final IoTConsensusServerImpl serverImpl = mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(walNode);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker());
+
+      final List<Long> conversionAttempts = new ArrayList<>();
+      final ConsensusLogToTabletConverter converter = mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenAnswer(
+              invocation -> {
+                final InsertNode insertNode = (InsertNode) invocation.getArgument(0);
+                conversionAttempts.add(insertNode.getSearchIndex());
+                return Collections.singletonList(createTablet());
+              });
+      when(converter.getDatabaseName()).thenReturn("db");
+
+      final DataRegionId regionId = new DataRegionId(1);
+      final ConsensusSubscriptionCommitManager commitManager = newCommitManager(systemDir);
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              commitManager,
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      final long memoryLimit = oneTabletBytes + Math.max(1L, oneTabletBytes / 2L);
+      final TrackingSubscriptionMemoryManager memoryManager =
+          new TrackingSubscriptionMemoryManager(memoryLimit);
+      queue.setSubscriptionMemoryManager(memoryManager);
+
+      assertNull(queue.poll("consumer"));
+      for (long searchIndex = 1L; searchIndex <= REQUEST_COUNT; searchIndex++) {
+        assertTrue(pendingEntries(queue).offer(createRequest(searchIndex)));
+      }
+
+      queue.drivePrefetchOnce();
+
+      assertEquals(Arrays.asList(1L, 2L), conversionAttempts);
+      assertEquals(2L, queue.getCurrentReadSearchIndex());
+      assertEquals(1, queue.getPrefetchedEventCount());
+      assertTrue(pendingEntries(queue).isEmpty());
+      assertEquals("true", queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+      assertMemoryBounded(queue, memoryManager);
+
+      final List<Long> deliveredLocalSequences = new ArrayList<>();
+      for (long expectedLocalSequence = 1L;
+          expectedLocalSequence <= REQUEST_COUNT;
+          expectedLocalSequence++) {
+        final SubscriptionEvent event = queue.poll("consumer");
+        assertNotNull(event);
+        final long deliveredLocalSequence =
+            event.getCommitContext().getWriterProgress().getLocalSeq();
+        assertEquals(expectedLocalSequence, deliveredLocalSequence);
+        deliveredLocalSequences.add(deliveredLocalSequence);
+
+        assertTrue(queue.ack("consumer", event.getCommitContext()));
+        assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+        assertMemoryBounded(queue, memoryManager);
+
+        queue.drivePrefetchOnce();
+        assertMemoryBounded(queue, memoryManager);
+        if (expectedLocalSequence < REQUEST_COUNT) {
+          assertEquals(1, queue.getPrefetchedEventCount());
+          assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+        }
+      }
+
+      assertEquals(Arrays.asList(1L, 2L, 3L), deliveredLocalSequences);
+      assertEquals(Arrays.asList(1L, 2L, 2L, 3L, 3L), conversionAttempts);
+      assertEquals(1L, queue.getPendingPathAcceptedEntries());
+      assertEquals(2L, queue.getWalPathAcceptedEntries());
+      assertEquals(4L, queue.getCurrentReadSearchIndex());
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertNull(queue.poll("consumer"));
+      assertEquals(
+          new WriterProgress(1000L + REQUEST_COUNT, REQUEST_COUNT),
+          commitManager
+              .getCommittedRegionProgress("consumerGroup", "topic", regionId)
+              .getWriterPositions()
+              .get(new WriterId(regionId.toString(), WRITER_NODE_ID)));
+      assertEquals(oneTabletBytes, memoryManager.getMaximumUsedMemorySizeInBytes());
+      assertFalse(memoryManager.hasExceededBudget());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      config.setSubscriptionConsensusBatchMaxWalEntries(originalBatchMaxWalEntries);
+      config.setSubscriptionConsensusBatchMaxTabletCount(originalBatchMaxTabletCount);
+      config.setSubscriptionConsensusBatchMaxSizeInBytes(originalBatchMaxSize);
+      config.setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  private static void writeSealedWal(final File walDirectory) throws Exception {
+    final File historicalWal =
+        new File(
+            walDirectory, WALFileUtils.getLogFileName(0L, 0L, WALFileStatus.CONTAINS_SEARCH_INDEX));
+    final File liveWal =
+        new File(
+            walDirectory,
+            WALFileUtils.getLogFileName(1L, REQUEST_COUNT, WALFileStatus.CONTAINS_SEARCH_INDEX));
+
+    try (WALWriter writer = new WALWriter(historicalWal, WALFileVersion.V3)) {
+      for (long searchIndex = 1L; searchIndex <= REQUEST_COUNT; searchIndex++) {
+        writeWalEntry(writer, searchIndex);
+      }
+    }
+    try (WALWriter ignored = new WALWriter(liveWal, WALFileVersion.V3)) {
+      // Keep an empty live successor so the WAL containing the requests is historical and readable.
+    }
+  }
+
+  private static void writeWalEntry(final WALWriter writer, final long searchIndex)
+      throws Exception {
+    final RelationalInsertRowNode insertNode =
+        StatementTestUtils.genInsertRowNode(Math.toIntExact(searchIndex));
+    insertNode.setSearchIndex(searchIndex);
+    insertNode.setLastFragment(true);
+    final WALInfoEntry walEntry = new WALInfoEntry(1L, insertNode);
+    final ByteBuffer buffer = ByteBuffer.allocate(walEntry.serializedSize());
+    walEntry.serialize(new WALByteBufferForTest(buffer));
+
+    final WALMetaData metaData = new WALMetaData();
+    metaData.add(
+        walEntry.serializedSize(),
+        searchIndex,
+        1L,
+        1000L + searchIndex,
+        WRITER_NODE_ID,
+        searchIndex);
+    writer.write(buffer, metaData);
+  }
+
+  private static IndexedConsensusRequest createRequest(final long searchIndex) {
+    return new IndexedConsensusRequest(
+            searchIndex,
+            Collections.singletonList(
+                StatementTestUtils.genInsertRowNode(Math.toIntExact(searchIndex))))
+        .setPhysicalTime(1000L + searchIndex)
+        .setNodeId(WRITER_NODE_ID);
+  }
+
+  private static Tablet createTablet() {
+    final List<String> columnNames = Arrays.asList("device", "temperature");
+    final List<TSDataType> dataTypes = Arrays.asList(TSDataType.STRING, TSDataType.DOUBLE);
+    final List<ColumnCategory> categories = Arrays.asList(ColumnCategory.TAG, ColumnCategory.FIELD);
+    final Tablet tablet = new Tablet("sensors", columnNames, dataTypes, categories, 1);
+    tablet.addTimestamp(0, 1L);
+    tablet.addValue(0, 0, "d1");
+    tablet.addValue(0, 1, 36.5);
+    tablet.setRowSize(1);
+    return tablet;
+  }
+
+  private static void assertMemoryBounded(
+      final ConsensusPrefetchingQueue queue,
+      final TrackingSubscriptionMemoryManager memoryManager) {
+    assertEquals(queue.getRetainedTabletBytes(), memoryManager.getUsedMemorySizeInBytes());
+    assertTrue(
+        memoryManager.getUsedMemorySizeInBytes() <= memoryManager.getTotalMemorySizeInBytes());
+    assertFalse(memoryManager.hasExceededBudget());
+  }
+
+  @SuppressWarnings("unchecked")
+  private static BlockingQueue<IndexedConsensusRequest> pendingEntries(
+      final ConsensusPrefetchingQueue queue) throws Exception {
+    final Field field = ConsensusPrefetchingQueue.class.getDeclaredField("pendingEntries");
+    field.setAccessible(true);
+    return (BlockingQueue<IndexedConsensusRequest>) field.get(queue);
+  }
+
+  private static ConsensusSubscriptionCommitManager newCommitManager(final File systemDir)
+      throws Exception {
+    IoTDBDescriptor.getInstance().getConfig().setSystemDir(systemDir.getAbsolutePath());
+    final Constructor<ConsensusSubscriptionCommitManager> constructor =
+        ConsensusSubscriptionCommitManager.class.getDeclaredConstructor();
+    constructor.setAccessible(true);
+    return constructor.newInstance();
+  }
+
+  private static final class TrackingSubscriptionMemoryManager extends SubscriptionMemoryManager {
+
+    private long maximumUsedMemorySizeInBytes;
+    private boolean exceededBudget;
+
+    private TrackingSubscriptionMemoryManager(final long totalMemorySizeInBytes) {
+      super(totalMemorySizeInBytes);
+    }
+
+    @Override
+    public synchronized boolean tryAllocate(final long sizeInBytes) {
+      final boolean allocated = super.tryAllocate(sizeInBytes);
+      observeUsage();
+      return allocated;
+    }
+
+    @Override
+    public synchronized void release(final long sizeInBytes) {
+      super.release(sizeInBytes);
+      observeUsage();
+    }
+
+    private void observeUsage() {
+      final long usedMemorySizeInBytes = getUsedMemorySizeInBytes();
+      maximumUsedMemorySizeInBytes = Math.max(maximumUsedMemorySizeInBytes, usedMemorySizeInBytes);
+      exceededBudget |= usedMemorySizeInBytes > getTotalMemorySizeInBytes();
+    }
+
+    private long getMaximumUsedMemorySizeInBytes() {
+      return maximumUsedMemorySizeInBytes;
+    }
+
+    private boolean hasExceededBudget() {
+      return exceededBudget;
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManagerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManagerTest.java
new file mode 100644
index 00000000000..1635a6e0ecf
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManagerTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.subscription.resource;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class SubscriptionMemoryManagerTest {
+
+  @Test
+  public void testAllocateAndReleaseWithinBudget() {
+    final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(10L);
+
+    assertTrue(memoryManager.tryAllocate(6L));
+    assertFalse(memoryManager.tryAllocate(5L));
+    assertEquals(6L, memoryManager.getUsedMemorySizeInBytes());
+    assertEquals(4L, memoryManager.getFreeMemorySizeInBytes());
+
+    memoryManager.release(6L);
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    assertEquals(10L, memoryManager.getFreeMemorySizeInBytes());
+  }
+
+  @Test
+  public void testAllowOnlyOneOversizedEntryWhenBudgetIsEmpty() {
+    final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(10L);
+
+    assertTrue(memoryManager.tryAllocate(11L));
+    assertFalse(memoryManager.tryAllocate(1L));
+    assertEquals(11L, memoryManager.getUsedMemorySizeInBytes());
+
+    memoryManager.release(11L);
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    assertTrue(memoryManager.tryAllocate(10L));
+  }
+
+  @Test
+  public void testZeroBudgetRejectsAllocation() {
+    final SubscriptionMemoryManager memoryManager = new SubscriptionMemoryManager(0L);
+
+    assertFalse(memoryManager.tryAllocate(1L));
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+  }
+}
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 c54878bfe1b..5ccbf54da96 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
@@ -1131,10 +1131,12 @@ meta_data_cache_enable=true
 # Datatype: boolean
 may_cache_nonexist_series=true
 
-# Read memory Allocation Ratio: BloomFilterCache : ChunkCache : TimeSeriesMetadataCache : Coordinator : Operators : DataExchange : timeIndex in TsFileResourceList : others.
-# The parameter form is a:b:c:d:e:f:g:h, where a, b, c, d, e, f, g and h are integers. for example: 1:1:1:1:1:1:1:1 , 1:100:200:50:200:200:200:50
+# Read memory Allocation Ratio: BloomFilterCache : ChunkCache : TimeSeriesMetadataCache : Coordinator : Operators : DataExchange : timeIndex in TsFileResourceList : others : Subscription.
+# The parameter form is a:b:c:d:e:f:g:h:i, where a, b, c, d, e, f, g, h and i are integers. for example: 1:1:1:1:1:1:1:1:1 , 1:100:200:50:200:200:200:50:250
+# The legacy eight-part form is still accepted and defaults Subscription to 20% of query memory.
+# When subscription is disabled, its memory proportion is 0.
 # effectiveMode: restart
-chunk_timeseriesmeta_free_memory_proportion=1:100:200:50:200:200:200:50
+chunk_timeseriesmeta_free_memory_proportion=1:100:200:50:200:200:200:50:250
 
 # Whether to enable LAST cache
 # effectiveMode: restart