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

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


The following commit(s) were added to refs/heads/master by this push:
     new 21d53dd015c Bound subscription memory borrowing and report admission 
failures (#18812)
21d53dd015c is described below

commit 21d53dd015c43c230914076619cc34ca636a3882
Author: Caideyipi <[email protected]>
AuthorDate: Sat Oct 10 11:17:17 2026 +0800

    Bound subscription memory borrowing and report admission failures (#18812)
    
    * Isolate subscription materialization memory by queue
    
    * Allow bounded borrowing of idle subscription memory
---
 .../iotdb/consensus/i18n/IoTConsensusMessages.java |   4 +
 .../iotdb/consensus/i18n/IoTConsensusMessages.java |   4 +
 .../subscription/SubscriptionQueueAdmission.java   |  25 ++
 .../subscription/SubscriptionQueueRegistry.java    |  39 ++-
 .../SubscriptionQueueRejectionReason.java          |  42 +++
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |   2 +
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |   2 +
 .../consensus/ConsensusPrefetchingQueue.java       | 258 +++++++++++++---
 ...nsensusSubscriptionPrefetchingQueueMetrics.java |  90 ++++++
 .../resource/SubscriptionMemoryManager.java        | 335 +++++++++++++++++++--
 ...onsensusPrefetchingQueueDataNodeMemoryTest.java | 249 +++++++++++++--
 .../consensus/ConsensusPrefetchingQueueTest.java   | 115 ++++++-
 ...nsensusPrefetchingQueueWalBackpressureTest.java |  19 +-
 ...susSubscriptionPrefetchingQueueMetricsTest.java |  23 ++
 .../resource/SubscriptionMemoryManagerTest.java    | 108 +++++++
 .../conf/iotdb-system.properties.template          |  29 ++
 .../iotdb/commons/service/metric/enums/Metric.java |   8 +
 .../iotdb/commons/service/metric/enums/Tag.java    |   1 +
 18 files changed, 1231 insertions(+), 122 deletions(-)

diff --git 
a/iotdb-core/consensus/src/main/i18n/en/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
 
b/iotdb-core/consensus/src/main/i18n/en/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
index 7f6efd07aeb..6c1e4c1dc17 100644
--- 
a/iotdb-core/consensus/src/main/i18n/en/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
+++ 
b/iotdb-core/consensus/src/main/i18n/en/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
@@ -330,6 +330,10 @@ public final class IoTConsensusMessages {
   public static final String 
LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ARG_ENTRY_S_LAST_ARG_MS_2AD8AB3D = 
"Subscription queue full, dropped {} entry(s) in the last {} ms, latest ";
   public static final String 
LOG_SEARCHINDEX_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_2EA619ED = 
"searchIndex={}, queueSize={}, queueRemaining={}";
   public static final String 
LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ENTRY_SEARCHINDEX_ARG_DROPPEDCOUNT_ARG_61F126B8
 = "Subscription queue full, dropped entry searchIndex={}, droppedCount={}";
+  public static final String 
LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ARG_ENTRY_S_IN_THE_LAST_ARG_MS_WAL_REPLAY_REQUIRED_GROUP_ARG_LATEST_SEARCHINDEX_ARG_REASONCODE_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_0FBF6226
 =
+      "Subscription realtime admission rejected {} entry(s) in the last {} ms; 
WAL replay required, group={}, latest searchIndex={}, reasonCode={}, 
queueSize={}, queueRemaining={}";
+  public static final String 
LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ENTRY_WAL_REPLAY_REQUIRED_GROUP_ARG_SEARCHINDEX_ARG_REASONCODE_ARG_REJECTEDCOUNT_ARG_7F76D6A9
 =
+      "Subscription realtime admission rejected entry; WAL replay required, 
group={}, searchIndex={}, reasonCode={}, rejectedCount={}";
   public static final String 
LOG_RESERVED_ARG_BYTES_BATCH_ARG_ARG_CURRENT_TOTAL_USAGE_ARG_308AE9C2 = 
"Reserved {} bytes for batch {}-{}, current total usage {}";
   public static final String 
LOG_ARG_FAILED_SEND_IDLE_WRITER_SAFE_TIME_BARRIER_ARG_STATUS_AE047EAD = "{}: 
Failed to send idle writer safe-time barrier to {}. status={}";
   public static final String 
LOG_ARG_WRITE_OPERATION_FAILED_SEARCHINDEX_ARG_CODE_ARG_SUBSCRIPTIONQUEUES_ARG_THIS_ARG_F4B17576
 = "{}: write operation failed. searchIndex: {}. Code: {}, subscriptionQueues: 
{}, this: {}";
diff --git 
a/iotdb-core/consensus/src/main/i18n/zh/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
 
b/iotdb-core/consensus/src/main/i18n/zh/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
index 7cc8ac622d1..a7ef49bf467 100644
--- 
a/iotdb-core/consensus/src/main/i18n/zh/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
+++ 
b/iotdb-core/consensus/src/main/i18n/zh/org/apache/iotdb/consensus/i18n/IoTConsensusMessages.java
@@ -328,6 +328,10 @@ public final class IoTConsensusMessages {
   public static final String 
LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ARG_ENTRY_S_LAST_ARG_MS_2AD8AB3D = 
"订阅队列已满,丢弃了 {} 个 entry,最近 {} ms,最新 ";
   public static final String 
LOG_SEARCHINDEX_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_2EA619ED = 
"searchIndex={}, queueSize={}, queueRemaining={}";
   public static final String 
LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ENTRY_SEARCHINDEX_ARG_DROPPEDCOUNT_ARG_61F126B8
 = "订阅队列已满,丢弃 entry,searchIndex={},droppedCount={}";
+  public static final String 
LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ARG_ENTRY_S_IN_THE_LAST_ARG_MS_WAL_REPLAY_REQUIRED_GROUP_ARG_LATEST_SEARCHINDEX_ARG_REASONCODE_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_0FBF6226
 =
+      "订阅实时准入拒绝了 {} 个 entry(最近 {} ms);需通过 WAL 回放补齐,group={},最新 
searchIndex={},reasonCode={},queueSize={},queueRemaining={}";
+  public static final String 
LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ENTRY_WAL_REPLAY_REQUIRED_GROUP_ARG_SEARCHINDEX_ARG_REASONCODE_ARG_REJECTEDCOUNT_ARG_7F76D6A9
 =
+      "订阅实时准入拒绝 entry;需通过 WAL 
回放补齐,group={},searchIndex={},reasonCode={},rejectedCount={}";
   public static final String 
LOG_RESERVED_ARG_BYTES_BATCH_ARG_ARG_CURRENT_TOTAL_USAGE_ARG_308AE9C2 = "预留 {} 
字节给批次 {}-{},当前总使用量 {}";
   public static final String 
LOG_ARG_FAILED_SEND_IDLE_WRITER_SAFE_TIME_BARRIER_ARG_STATUS_AE047EAD = "{}:无法向 
{} 发送 idle writer safe-time barrier。状态={}";
   public static final String 
LOG_ARG_WRITE_OPERATION_FAILED_SEARCHINDEX_ARG_CODE_ARG_SUBSCRIPTIONQUEUES_ARG_THIS_ARG_F4B17576
 = "{}:写入操作失败。searchIndex: {}。Code: {},订阅队列:{},当前对象:{}";
diff --git 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueAdmission.java
 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueAdmission.java
new file mode 100644
index 00000000000..18c43fd7e16
--- /dev/null
+++ 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueAdmission.java
@@ -0,0 +1,25 @@
+/*
+ * 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.consensus.iot.subscription;
+
+/** Exposes the reason for the calling thread's most recent subscription queue 
offer rejection. */
+public interface SubscriptionQueueAdmission {
+
+  SubscriptionQueueRejectionReason getLastRejectionReason();
+}
diff --git 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRegistry.java
 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRegistry.java
index d63e42f12c9..0cc06eeff52 100644
--- 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRegistry.java
+++ 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRegistry.java
@@ -32,20 +32,22 @@ import java.util.Map;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.LongSupplier;
 
 public class SubscriptionQueueRegistry {
 
   private static final Logger LOGGER = 
LoggerFactory.getLogger(SubscriptionQueueRegistry.class);
 
-  private static final long QUEUE_FULL_LOG_INTERVAL_MS = 
TimeUnit.SECONDS.toMillis(10);
+  private static final long REJECTION_LOG_INTERVAL_MS = 
TimeUnit.SECONDS.toMillis(10);
 
   private final String consensusGroupId;
   private final Map<BlockingQueue<IndexedConsensusRequest>, 
SubscriptionQueueRegistration> queues =
       new ConcurrentHashMap<>();
-  private final AtomicLong droppedEntries = new AtomicLong();
-  private final AtomicLong lastDropLogTimeMs = new AtomicLong();
+  // offer is synchronized, so these counts remain scoped to one reason 
without extra atomics.
+  private final long[] rejectedEntriesByReason =
+      new long[SubscriptionQueueRejectionReason.values().length];
+  private final long[] lastRejectionLogTimeMsByReason =
+      new long[SubscriptionQueueRejectionReason.values().length];
 
   public SubscriptionQueueRegistry(final String consensusGroupId) {
     this.consensusGroupId = consensusGroupId;
@@ -141,27 +143,34 @@ public class SubscriptionQueueRegistry {
             queue.remainingCapacity());
       }
       if (!offered) {
-        final long droppedCount = droppedEntries.incrementAndGet();
+        final SubscriptionQueueRejectionReason rejectionReason =
+            queue instanceof SubscriptionQueueAdmission
+                ? ((SubscriptionQueueAdmission) queue).getLastRejectionReason()
+                : SubscriptionQueueRejectionReason.QUEUE_CAPACITY;
+        final int reasonIndex = rejectionReason.ordinal();
+        final long rejectedCount = ++rejectedEntriesByReason[reasonIndex];
         final long now = System.currentTimeMillis();
-        final long lastLogTime = lastDropLogTimeMs.get();
-        if (now - lastLogTime >= QUEUE_FULL_LOG_INTERVAL_MS
-            && lastDropLogTimeMs.compareAndSet(lastLogTime, now)) {
+        if (now - lastRejectionLogTimeMsByReason[reasonIndex] >= 
REJECTION_LOG_INTERVAL_MS) {
+          lastRejectionLogTimeMsByReason[reasonIndex] = now;
+          rejectedEntriesByReason[reasonIndex] = 0L;
           LOGGER.warn(
               IoTConsensusMessages
-                      
.LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ARG_ENTRY_S_LAST_ARG_MS_2AD8AB3D
-                  + IoTConsensusMessages
-                      
.LOG_SEARCHINDEX_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_2EA619ED,
-              droppedEntries.getAndSet(0),
-              QUEUE_FULL_LOG_INTERVAL_MS,
+                  
.LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ARG_ENTRY_S_IN_THE_LAST_ARG_MS_WAL_REPLAY_REQUIRED_GROUP_ARG_LATEST_SEARCHINDEX_ARG_REASONCODE_ARG_QUEUESIZE_ARG_QUEUEREMAINING_ARG_0FBF6226,
+              rejectedCount,
+              REJECTION_LOG_INTERVAL_MS,
+              consensusGroupId,
               indexedConsensusRequest.getSearchIndex(),
+              rejectionReason.getCode(),
               queue.size(),
               queue.remainingCapacity());
         } else if (LOGGER.isDebugEnabled()) {
           LOGGER.debug(
               IoTConsensusMessages
-                  
.LOG_SUBSCRIPTION_QUEUE_FULL_DROPPED_ENTRY_SEARCHINDEX_ARG_DROPPEDCOUNT_ARG_61F126B8,
+                  
.LOG_SUBSCRIPTION_REALTIME_ADMISSION_REJECTED_ENTRY_WAL_REPLAY_REQUIRED_GROUP_ARG_SEARCHINDEX_ARG_REASONCODE_ARG_REJECTEDCOUNT_ARG_7F76D6A9,
+              consensusGroupId,
               indexedConsensusRequest.getSearchIndex(),
-              droppedCount);
+              rejectionReason.getCode(),
+              rejectedCount);
         }
       }
     }
diff --git 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRejectionReason.java
 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRejectionReason.java
new file mode 100644
index 00000000000..852bc55e2f6
--- /dev/null
+++ 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/subscription/SubscriptionQueueRejectionReason.java
@@ -0,0 +1,42 @@
+/*
+ * 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.consensus.iot.subscription;
+
+/** Stable error codes used when a consensus subscription queue rejects 
realtime input. */
+public enum SubscriptionQueueRejectionReason {
+  NONE("NONE"),
+  QUEUE_CAPACITY("SUBSCRIPTION_QUEUE_CAPACITY"),
+  SUBSCRIPTION_MEMORY_QUOTA("SUBSCRIPTION_MEMORY_QUOTA"),
+  SUBSCRIPTION_MEMORY_LIMIT("SUBSCRIPTION_MEMORY_LIMIT"),
+  SUBSCRIPTION_OVERSIZED_ENTRY("SUBSCRIPTION_OVERSIZED_ENTRY"),
+  WRITER_BACKLOG("SUBSCRIPTION_WRITER_BACKLOG"),
+  SEEK_IN_PROGRESS("SUBSCRIPTION_SEEK_IN_PROGRESS"),
+  CONSENSUS_REQUEST_MEMORY_LIMIT("CONSENSUS_REQUEST_MEMORY_LIMIT"),
+  INACTIVE_OR_CLOSED("SUBSCRIPTION_QUEUE_INACTIVE_OR_CLOSED");
+
+  private final String code;
+
+  SubscriptionQueueRejectionReason(final String code) {
+    this.code = code;
+  }
+
+  public String getCode() {
+    return code;
+  }
+}
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index 5e92afb5f9c..3cd7ae14585 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2644,4 +2644,6 @@ public final class DataNodePipeMessages {
       "Interrupted while reading OPC UA server operation limits, use defaults: 
maxNodesPerWrite={}, maxNodesPerNodeManagement={}";
   public static final String 
LOG_FAILED_TO_READ_OPC_UA_SERVER_OPERATION_LIMITS_USE_DEFAULTS_MAXNODESPERWRITE_ARG_MAXNODESPERNODEMANAGEMENT_ARG_65460871
 =
       "Failed to read OPC UA server operation limits, use defaults: 
maxNodesPerWrite={}, maxNodesPerNodeManagement={}";
+  public static final String 
MESSAGE_ARG_SUBSCRIPTION_ENTRY_REQUIRES_ARG_BYTES_ABOVE_THE_CURRENT_PER_QUEUE_MAXIMUM_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_SUBSCRIPTION_MATERIALIZATION_MEMORY_WAL_PROGRESS_HAS_NOT_ADVANCED_AFCBC7FC
 =
+      "[%s] Subscription entry requires %d bytes, above the current per-queue 
maximum %d bytes (DataNode budget %d bytes). Reduce the write batch/field size 
or increase subscription materialization memory. WAL progress has not 
advanced.";
 }
diff --git 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index 14bc9cd4b26..23f58a00c51 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2469,4 +2469,6 @@ public final class DataNodePipeMessages {
       "读取 OPC UA 
服务器操作限制时被中断,使用默认值:maxNodesPerWrite={},maxNodesPerNodeManagement={}";
   public static final String 
LOG_FAILED_TO_READ_OPC_UA_SERVER_OPERATION_LIMITS_USE_DEFAULTS_MAXNODESPERWRITE_ARG_MAXNODESPERNODEMANAGEMENT_ARG_65460871
 =
       "读取 OPC UA 
服务器操作限制失败,使用默认值:maxNodesPerWrite={},maxNodesPerNodeManagement={}";
+  public static final String 
MESSAGE_ARG_SUBSCRIPTION_ENTRY_REQUIRES_ARG_BYTES_ABOVE_THE_CURRENT_PER_QUEUE_MAXIMUM_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_SUBSCRIPTION_MATERIALIZATION_MEMORY_WAL_PROGRESS_HAS_NOT_ADVANCED_AFCBC7FC
 =
+      "[%s] 订阅 entry 需要 %d 字节,超过当前队列可用上限 %d 字节(DataNode 预算 %d 字节)。请减小写入 
batch/字段大小或增加订阅物化内存。WAL 进度未推进。";
 }
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 22fd01f6c47..b8cd6b258d3 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
@@ -30,6 +30,8 @@ import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
 import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
 import org.apache.iotdb.consensus.iot.log.ConsensusReqReader;
 import org.apache.iotdb.consensus.iot.logdispatcher.IoTConsensusMemoryManager;
+import org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueAdmission;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
@@ -92,10 +94,11 @@ import java.util.concurrent.PriorityBlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicLongArray;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.BiConsumer;
-import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
@@ -151,6 +154,27 @@ public class ConsensusPrefetchingQueue {
 
   private SubscriptionMemoryManager subscriptionMemoryManager;
 
+  /** Queue-local view of the DataNode subscription memory budget. */
+  private SubscriptionMemoryManager.MemoryHandle subscriptionMemoryHandle;
+
+  private final AtomicLong realtimeAdmissionRejectionCount = new 
AtomicLong(0L);
+
+  private final AtomicLongArray realtimeAdmissionRejectionsByReason =
+      new AtomicLongArray(SubscriptionQueueRejectionReason.values().length);
+
+  private final AtomicLong subscriptionMemoryRejectionCount = new 
AtomicLong(0L);
+
+  private final AtomicLong oversizedEntryRejectionCount = new AtomicLong(0L);
+
+  private final AtomicReference<SubscriptionQueueRejectionReason> 
lastRejectionReason =
+      new AtomicReference<>(SubscriptionQueueRejectionReason.NONE);
+
+  private volatile SubscriptionQueueRejectionReason 
realtimeAdmissionBlockReason =
+      SubscriptionQueueRejectionReason.NONE;
+
+  private volatile SubscriptionQueueRejectionReason memoryBlockReason =
+      SubscriptionQueueRejectionReason.NONE;
+
   /**
    * 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.
@@ -415,30 +439,48 @@ public class ConsensusPrefetchingQueue {
   }
 
   private static final class WakeableIndexedConsensusQueue
-      extends LinkedBlockingDeque<IndexedConsensusRequest> {
+      extends LinkedBlockingDeque<IndexedConsensusRequest> implements 
SubscriptionQueueAdmission {
 
     private final Runnable wakeupHook;
-    private final BooleanSupplier admissionSupplier;
+    private final Supplier<SubscriptionQueueRejectionReason> admissionSupplier;
+    private final Consumer<SubscriptionQueueRejectionReason> rejectionListener;
     private final IoTConsensusMemoryManager requestMemoryManager =
         IoTConsensusMemoryManager.getInstance();
     private final AtomicLong retainedRequestBytes = new AtomicLong(0L);
+    // The registry reads this immediately after offer on the same writer 
thread. A queue-wide
+    // value could be overwritten by another writer before the registry logs 
the rejection.
+    private final ThreadLocal<SubscriptionQueueRejectionReason> 
lastOfferRejectionReason =
+        ThreadLocal.withInitial(() -> SubscriptionQueueRejectionReason.NONE);
 
     private WakeableIndexedConsensusQueue(
-        final int capacity, final Runnable wakeupHook, final BooleanSupplier 
admissionSupplier) {
+        final int capacity,
+        final Runnable wakeupHook,
+        final Supplier<SubscriptionQueueRejectionReason> admissionSupplier,
+        final Consumer<SubscriptionQueueRejectionReason> rejectionListener) {
       super(capacity);
       this.wakeupHook = wakeupHook;
       this.admissionSupplier = admissionSupplier;
+      this.rejectionListener = rejectionListener;
+    }
+
+    private boolean reject(final SubscriptionQueueRejectionReason reason) {
+      final SubscriptionQueueRejectionReason effectiveReason =
+          Objects.isNull(reason) ? 
SubscriptionQueueRejectionReason.QUEUE_CAPACITY : reason;
+      lastOfferRejectionReason.set(effectiveReason);
+      rejectionListener.accept(effectiveReason);
+      return false;
     }
 
     @Override
     public boolean offer(final IndexedConsensusRequest request) {
       final boolean offered;
       synchronized (this) {
-        if (!admissionSupplier.getAsBoolean()) {
-          return false;
+        final SubscriptionQueueRejectionReason admissionReason = 
admissionSupplier.get();
+        if (admissionReason != SubscriptionQueueRejectionReason.NONE) {
+          return reject(admissionReason);
         }
         if (!requestMemoryManager.reserve(request)) {
-          return false;
+          return 
reject(SubscriptionQueueRejectionReason.CONSENSUS_REQUEST_MEMORY_LIMIT);
         }
         try {
           offered = super.offer(request);
@@ -450,6 +492,7 @@ public class ConsensusPrefetchingQueue {
           retainedRequestBytes.addAndGet(request.getRetainedMemorySize());
         } else {
           requestMemoryManager.free(request);
+          reject(SubscriptionQueueRejectionReason.QUEUE_CAPACITY);
         }
       }
       if (offered) {
@@ -478,6 +521,11 @@ public class ConsensusPrefetchingQueue {
     private long getRetainedRequestBytes() {
       return retainedRequestBytes.get();
     }
+
+    @Override
+    public SubscriptionQueueRejectionReason getLastRejectionReason() {
+      return lastOfferRejectionReason.get();
+    }
   }
 
   private static final class PendingSeekRequest {
@@ -559,6 +607,8 @@ public class ConsensusPrefetchingQueue {
     this.tableModel = converter.isTableModel();
     this.commitManager = commitManager;
     this.subscriptionMemoryManager = 
SubscriptionDataNodeResourceManager.memory();
+    this.subscriptionMemoryHandle = subscriptionMemoryManager.registerQueue();
+    this.subscriptionMemoryHandle.setActive(initialActive);
     this.fallbackCommittedRegionProgress = fallbackCommittedRegionProgress;
     this.fallbackTailSearchIndex = tailStartSearchIndex;
     this.runtimeVersion = initialRuntimeVersion;
@@ -577,7 +627,8 @@ public class ConsensusPrefetchingQueue {
         new WakeableIndexedConsensusQueue(
             PENDING_QUEUE_CAPACITY,
             this::requestPrefetchForRealtimeEntry,
-            this::canAcceptRealtimeEntry);
+            this::getRealtimeAdmissionRejectionReason,
+            this::recordRealtimeAdmissionRejection);
     serverImpl.registerSubscriptionQueue(
         pendingEntries, retentionPolicy, 
this::getRequiredRetainedMinVersionId);
 
@@ -602,7 +653,12 @@ public class ConsensusPrefetchingQueue {
     if (retainedTabletBytes.get() != 0L) {
       throw new IllegalStateException();
     }
+    if (Objects.nonNull(this.subscriptionMemoryHandle)) {
+      this.subscriptionMemoryHandle.close();
+    }
     this.subscriptionMemoryManager = 
Objects.requireNonNull(subscriptionMemoryManager);
+    this.subscriptionMemoryHandle = 
this.subscriptionMemoryManager.registerQueue();
+    this.subscriptionMemoryHandle.setActive(isActive);
     memoryBlockedEntryBytes = -1L;
     realtimeAdmissionBlocked.set(false);
   }
@@ -641,23 +697,53 @@ public class ConsensusPrefetchingQueue {
     }
   }
 
-  private boolean canAcceptRealtimeEntry() {
-    return isActive
-        && !closeRequested
-        && !isClosed
-        && !realtimeAdmissionBlocked.get()
-        && subscriptionMemoryManager.getFreeMemorySizeInBytes() > 0L;
+  private SubscriptionQueueRejectionReason 
getRealtimeAdmissionRejectionReason() {
+    if (!isActive || closeRequested || isClosed) {
+      return SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED;
+    }
+    if (realtimeAdmissionBlocked.get()) {
+      return realtimeAdmissionBlockReason;
+    }
+    // Allow an empty queue to probe an entry even with a zero quota so 
materialization can report
+    // an explicit oversized-entry error. Pending requests have separate 
consensus memory
+    // accounting.
+    if (subscriptionMemoryHandle.getUsedMemorySizeInBytes() > 0L
+        && subscriptionMemoryHandle.getFreeMemorySizeInBytes() <= 0L) {
+      return 
toQueueRejectionReason(subscriptionMemoryHandle.inspectRejection(1L));
+    }
+    return SubscriptionQueueRejectionReason.NONE;
   }
 
-  private void blockRealtimeAdmission() {
+  private void blockRealtimeAdmission(final SubscriptionQueueRejectionReason 
reason) {
+    realtimeAdmissionBlockReason = reason;
     realtimeAdmissionBlocked.set(true);
-    pendingEntries.clear();
   }
 
   private void unblockRealtimeAdmission() {
     realtimeAdmissionBlocked.set(false);
   }
 
+  private void recordRealtimeAdmissionRejection(
+      final SubscriptionQueueRejectionReason rejectionReason) {
+    if (Objects.isNull(rejectionReason)
+        || rejectionReason == SubscriptionQueueRejectionReason.NONE) {
+      return;
+    }
+    lastRejectionReason.set(rejectionReason);
+    realtimeAdmissionRejectionCount.incrementAndGet();
+    
realtimeAdmissionRejectionsByReason.incrementAndGet(rejectionReason.ordinal());
+    recordMemoryRejection(rejectionReason);
+  }
+
+  private void recordMemoryRejection(final SubscriptionQueueRejectionReason 
rejectionReason) {
+    if (rejectionReason == 
SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA
+        || rejectionReason == 
SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_LIMIT) {
+      subscriptionMemoryRejectionCount.incrementAndGet();
+    } else if (rejectionReason == 
SubscriptionQueueRejectionReason.SUBSCRIPTION_OVERSIZED_ENTRY) {
+      oversizedEntryRejectionCount.incrementAndGet();
+    }
+  }
+
   private ConsensusPrefetchSubtask ensurePrefetchSubtaskBound() {
     if (closeRequested || isClosed) {
       return null;
@@ -777,6 +863,26 @@ public class ConsensusPrefetchingQueue {
       } else if (Objects.isNull(event) && 
shouldRecoverPrefetchBindingAfterEmptyPoll()) {
         requestPrefetch();
       }
+      // Deliver reserved events first so ACK can drain memory. An entry that 
cannot fit even in an
+      // empty queue is an explicit configuration error, with no replay/commit 
progress advancement.
+      final long blockedEntryBytes = memoryBlockedEntryBytes;
+      if (Objects.isNull(event)
+          && blockedEntryBytes > 0L
+          && subscriptionMemoryHandle.inspectRejection(blockedEntryBytes)
+              == 
SubscriptionMemoryManager.AllocationRejectionReason.OVERSIZED_ENTRY) {
+        return new SubscriptionEvent(
+            SubscriptionPollResponseType.ERROR.getType(),
+            new ErrorPayload(
+                String.format(
+                    DataNodePipeMessages
+                        
.MESSAGE_ARG_SUBSCRIPTION_ENTRY_REQUIRES_ARG_BYTES_ABOVE_THE_CURRENT_PER_QUEUE_MAXIMUM_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_SUBSCRIPTION_MATERIALIZATION_MEMORY_WAL_PROGRESS_HAS_NOT_ADVANCED_AFCBC7FC,
+                    
SubscriptionQueueRejectionReason.SUBSCRIPTION_OVERSIZED_ENTRY.getCode(),
+                    blockedEntryBytes,
+                    subscriptionMemoryHandle.getMaximumMemorySizeInBytes(),
+                    subscriptionMemoryManager.getTotalMemorySizeInBytes()),
+                true),
+            
createNonCommittableContext(IoTDBDescriptor.getInstance().getConfig().getDataNodeId()));
+      }
       return event;
     } finally {
       releaseReadLock();
@@ -1434,7 +1540,7 @@ public class ConsensusPrefetchingQueue {
       recycleInFlightEvents();
 
       if (!isActive) {
-        blockRealtimeAdmission();
+        
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
         return computeIdleRoundResult();
       }
       if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE) {
@@ -1457,12 +1563,12 @@ public class ConsensusPrefetchingQueue {
       if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE
           || !realtimeEntriesByWriter.isEmpty()) {
         if (!realtimeEntriesByWriter.isEmpty()) {
-          blockRealtimeAdmission();
+          
blockRealtimeAdmission(SubscriptionQueueRejectionReason.WRITER_BACKLOG);
         }
         return computeIdleRoundResult();
       }
       if (shouldWaitForSubscriptionMemory()) {
-        blockRealtimeAdmission();
+        blockRealtimeAdmission(memoryBlockReason);
         if (!lingerBatch.isEmpty() && !flushBatch(lingerBatch, 
observedSeekGeneration)) {
           resetRoundStateForSeek(seekGeneration.get());
           return PrefetchRoundResult.rescheduleNow();
@@ -1507,7 +1613,7 @@ public class ConsensusPrefetchingQueue {
           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();
+            blockRealtimeAdmission(memoryBlockReason);
             if (!lingerBatch.isEmpty() && !flushBatch(lingerBatch, 
observedSeekGeneration)) {
               resetRoundStateForSeek(seekGeneration.get());
               return PrefetchRoundResult.rescheduleNow();
@@ -1522,7 +1628,7 @@ public class ConsensusPrefetchingQueue {
       if (batch.isEmpty() && lingerBatch.isEmpty()) {
         final MaterializationResult walResult = 
tryCatchUpFromWAL(observedSeekGeneration);
         if (walResult == MaterializationResult.MEMORY_BLOCKED) {
-          blockRealtimeAdmission();
+          blockRealtimeAdmission(memoryBlockReason);
           return PrefetchRoundResult.rescheduleAfter(MEMORY_RETRY_SLEEP_MS);
         }
         if (walResult == MaterializationResult.STALE) {
@@ -2327,11 +2433,19 @@ public class ConsensusPrefetchingQueue {
       memoryBlockedEntryBytes = -1L;
       return true;
     }
-    if (!subscriptionMemoryManager.tryAllocate(bytes)) {
+    final SubscriptionMemoryManager.AllocationResult allocationResult =
+        subscriptionMemoryHandle.tryAllocate(bytes);
+    if (!allocationResult.isAccepted()) {
       memoryBlockedEntryBytes = bytes;
+      final SubscriptionQueueRejectionReason rejectionReason =
+          toQueueRejectionReason(allocationResult.getRejectionReason());
+      memoryBlockReason = rejectionReason;
+      lastRejectionReason.set(rejectionReason);
+      recordMemoryRejection(rejectionReason);
       return false;
     }
     memoryBlockedEntryBytes = -1L;
+    memoryBlockReason = SubscriptionQueueRejectionReason.NONE;
     retainedTabletBytes.addAndGet(bytes);
     return true;
   }
@@ -2339,22 +2453,21 @@ public class ConsensusPrefetchingQueue {
   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) {
+      if (subscriptionMemoryHandle.getUsedMemorySizeInBytes() > 0L
+          && subscriptionMemoryHandle.getFreeMemorySizeInBytes() <= 0L) {
+        memoryBlockReason = 
toQueueRejectionReason(subscriptionMemoryHandle.inspectRejection(1L));
+        return memoryBlockReason != SubscriptionQueueRejectionReason.NONE;
+      }
+      return false;
+    }
+    final SubscriptionMemoryManager.AllocationRejectionReason rejectionReason =
+        subscriptionMemoryHandle.inspectRejection(blockedEntryBytes);
+    if (rejectionReason == 
SubscriptionMemoryManager.AllocationRejectionReason.NONE) {
       memoryBlockedEntryBytes = -1L;
+      memoryBlockReason = SubscriptionQueueRejectionReason.NONE;
       return false;
     }
+    memoryBlockReason = toQueueRejectionReason(rejectionReason);
     return true;
   }
 
@@ -2372,7 +2485,22 @@ public class ConsensusPrefetchingQueue {
       releasedBytes = Math.min(bytes, currentRetainedBytes);
     } while (!retainedTabletBytes.compareAndSet(
         currentRetainedBytes, currentRetainedBytes - releasedBytes));
-    subscriptionMemoryManager.release(releasedBytes);
+    subscriptionMemoryHandle.release(releasedBytes);
+  }
+
+  private SubscriptionQueueRejectionReason toQueueRejectionReason(
+      final SubscriptionMemoryManager.AllocationRejectionReason 
rejectionReason) {
+    switch (rejectionReason) {
+      case NONE:
+        return SubscriptionQueueRejectionReason.NONE;
+      case MEMORY_QUOTA:
+        return SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA;
+      case OVERSIZED_ENTRY:
+        return SubscriptionQueueRejectionReason.SUBSCRIPTION_OVERSIZED_ENTRY;
+      case MEMORY_LIMIT:
+      default:
+        return SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_LIMIT;
+    }
   }
 
   private void reconcileRetainedTabletMemoryAfterCleanup() {
@@ -3038,7 +3166,7 @@ public class ConsensusPrefetchingQueue {
   // ======================== Cleanup ========================
 
   public void cleanUp() {
-    blockRealtimeAdmission();
+    
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
     acquireWriteLock();
     try {
       prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
@@ -3054,7 +3182,8 @@ public class ConsensusPrefetchingQueue {
       materializedProgressByWriter.clear();
       // 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();
+      
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
+      pendingEntries.clear();
       discardBatch(lingerBatch);
       reconcileRetainedTabletMemoryAfterCleanup();
       memoryBlockedEntryBytes = -1L;
@@ -3065,6 +3194,8 @@ public class ConsensusPrefetchingQueue {
       pendingSubscriptionWalResetSearchIndex = Long.MIN_VALUE;
       pendingSubscriptionWalResetGeneration = Long.MIN_VALUE;
       closeSubscriptionWALIterator();
+      subscriptionMemoryHandle.setActive(false);
+      subscriptionMemoryHandle.close();
 
     } finally {
       releaseWriteLock();
@@ -3323,7 +3454,7 @@ public class ConsensusPrefetchingQueue {
   }
 
   private void applySeekResetUnderWriteLock(final PendingSeekRequest request) {
-    blockRealtimeAdmission();
+    blockRealtimeAdmission(SubscriptionQueueRejectionReason.SEEK_IN_PROGRESS);
 
     // 1. Clean up all queued and in-flight events
     prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
@@ -3333,6 +3464,7 @@ public class ConsensusPrefetchingQueue {
     discardRetainedEventMemory();
 
     // 2. Discard stale pending entries from in-memory queue
+    pendingEntries.clear();
     memoryBlockedEntryBytes = -1L;
     unblockRealtimeAdmission();
 
@@ -4044,12 +4176,14 @@ public class ConsensusPrefetchingQueue {
     if (this.isActive != active) {
       if (active) {
         this.isActive = true;
+        subscriptionMemoryHandle.setActive(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);
+        subscriptionMemoryHandle.setActive(false);
+        
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
         acquireWriteLock();
         try {
           seekRequestToFail = pendingSeekRequest;
@@ -4400,6 +4534,39 @@ public class ConsensusPrefetchingQueue {
     return subscriptionMemoryManager.getTotalMemorySizeInBytes();
   }
 
+  public long getSubscriptionMemoryUsedInBytes() {
+    return subscriptionMemoryHandle.getUsedMemorySizeInBytes();
+  }
+
+  public long getSubscriptionMemoryQuotaInBytes() {
+    return subscriptionMemoryHandle.getMemoryQuotaInBytes();
+  }
+
+  public long getSubscriptionMemoryMaximumInBytes() {
+    return subscriptionMemoryHandle.getMaximumMemorySizeInBytes();
+  }
+
+  public long getRealtimeAdmissionRejectionCount() {
+    return realtimeAdmissionRejectionCount.get();
+  }
+
+  public long getRealtimeAdmissionRejectionCount(
+      final SubscriptionQueueRejectionReason rejectionReason) {
+    return realtimeAdmissionRejectionsByReason.get(rejectionReason.ordinal());
+  }
+
+  public long getSubscriptionMemoryRejectionCount() {
+    return subscriptionMemoryRejectionCount.get();
+  }
+
+  public long getOversizedEntryRejectionCount() {
+    return oversizedEntryRejectionCount.get();
+  }
+
+  public String getLastAdmissionRejectionCode() {
+    return lastRejectionReason.get().getCode();
+  }
+
   /** Exposes the current seek generation for runtime tests and metrics. */
   public long getCurrentSeekGeneration() {
     return seekGeneration.get();
@@ -4477,6 +4644,17 @@ public class ConsensusPrefetchingQueue {
     result.put(
         "subscriptionMemoryLimitInBytes",
         String.valueOf(subscriptionMemoryManager.getTotalMemorySizeInBytes()));
+    result.put("subscriptionMemoryUsedInBytes", 
String.valueOf(getSubscriptionMemoryUsedInBytes()));
+    result.put(
+        "subscriptionMemoryQuotaInBytes", 
String.valueOf(getSubscriptionMemoryQuotaInBytes()));
+    result.put(
+        "subscriptionMemoryMaximumInBytes", 
String.valueOf(getSubscriptionMemoryMaximumInBytes()));
+    result.put(
+        "realtimeAdmissionRejectionCount", 
String.valueOf(getRealtimeAdmissionRejectionCount()));
+    result.put(
+        "subscriptionMemoryRejectionCount", 
String.valueOf(getSubscriptionMemoryRejectionCount()));
+    result.put("oversizedEntryRejectionCount", 
String.valueOf(getOversizedEntryRejectionCount()));
+    result.put("lastAdmissionRejectionCode", getLastAdmissionRejectionCode());
     result.put("pendingPathAcceptedEntries", 
String.valueOf(getPendingPathAcceptedEntries()));
     result.put("walPathAcceptedEntries", 
String.valueOf(getWalPathAcceptedEntries()));
     result.put("seekGeneration", String.valueOf(seekGeneration.get()));
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
index 19076e9abae..0a549565b06 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
@@ -21,6 +21,7 @@ package org.apache.iotdb.db.subscription.metric;
 
 import org.apache.iotdb.commons.service.metric.enums.Metric;
 import org.apache.iotdb.commons.service.metric.enums.Tag;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
 import 
org.apache.iotdb.db.subscription.broker.consensus.ConsensusPrefetchingQueue;
 import org.apache.iotdb.metrics.AbstractMetricService;
@@ -199,6 +200,53 @@ public class ConsensusSubscriptionPrefetchingQueueMetrics 
implements IMetricSet
         queue,
         ConsensusPrefetchingQueue::getProgressStatus,
         key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_USED.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getSubscriptionMemoryUsedInBytes,
+        key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_QUOTA.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getSubscriptionMemoryQuotaInBytes,
+        key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_MAXIMUM.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getSubscriptionMemoryMaximumInBytes,
+        key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getRealtimeAdmissionRejectionCount,
+        key.getTags());
+    for (final SubscriptionQueueRejectionReason reason :
+        SubscriptionQueueRejectionReason.values()) {
+      if (reason != SubscriptionQueueRejectionReason.NONE) {
+        metricService.createAutoGauge(
+            
Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS_BY_REASON.toString(),
+            MetricLevel.IMPORTANT,
+            queue,
+            q -> q.getRealtimeAdmissionRejectionCount(reason),
+            key.getTagsWithReason(reason));
+      }
+    }
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_REJECTIONS.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getSubscriptionMemoryRejectionCount,
+        key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_OVERSIZED_REJECTIONS.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getOversizedEntryRejectionCount,
+        key.getTags());
   }
 
   private void createRate(final QueueMetricsKey key) {
@@ -309,6 +357,37 @@ public class ConsensusSubscriptionPrefetchingQueueMetrics 
implements IMetricSet
         MetricType.AUTO_GAUGE,
         Metric.SUBSCRIPTION_CONSENSUS_PROGRESS_STATUS.toString(),
         key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE, 
Metric.SUBSCRIPTION_CONSENSUS_MEMORY_USED.toString(), key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_QUOTA.toString(),
+        key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_MAXIMUM.toString(),
+        key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS.toString(),
+        key.getTags());
+    for (final SubscriptionQueueRejectionReason reason :
+        SubscriptionQueueRejectionReason.values()) {
+      if (reason != SubscriptionQueueRejectionReason.NONE) {
+        metricService.remove(
+            MetricType.AUTO_GAUGE,
+            
Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS_BY_REASON.toString(),
+            key.getTagsWithReason(reason));
+      }
+    }
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_MEMORY_REJECTIONS.toString(),
+        key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_OVERSIZED_REJECTIONS.toString(),
+        key.getTags());
   }
 
   private void removeRate(final QueueMetricsKey key) {
@@ -357,6 +436,17 @@ public class ConsensusSubscriptionPrefetchingQueueMetrics 
implements IMetricSet
       };
     }
 
+    private String[] getTagsWithReason(final SubscriptionQueueRejectionReason 
reason) {
+      return new String[] {
+        Tag.NAME.toString(),
+        queueId,
+        Tag.REGION.toString(),
+        regionId,
+        Tag.REASON.toString(),
+        reason.getCode(),
+      };
+    }
+
     @Override
     public boolean equals(final Object obj) {
       if (this == obj) {
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
index bf71fad6c4b..d250cd0ba16 100644
--- 
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
@@ -25,11 +25,29 @@ import org.apache.iotdb.commons.memory.MemoryBlockType;
 import org.apache.iotdb.commons.utils.TestOnly;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * DataNode-wide memory manager for materialized subscription data.
+ *
+ * <p>Consensus subscription queues use a handle rather than the legacy 
manager-level allocation
+ * methods. Handles are accounted independently. Each active queue keeps half 
of its equal share
+ * protected; another queue may borrow only the remaining idle space. Borrowed 
allocations are
+ * drained rather than revoked when the active queue set changes. An entry 
larger than the maximum
+ * amount one queue can hold under this policy is rejected explicitly.
+ */
 public class SubscriptionMemoryManager {
 
   private static final String MEMORY_BLOCK_NAME = "Subscription";
 
   private final IMemoryBlock memoryBlock;
+  private static final int PROTECTED_SHARE_DIVISOR = 2;
+  private final Map<Long, MemoryHandle> handles = new HashMap<>();
+  private long nextHandleId;
+
+  // Kept for callers of the original manager-level API. It is deliberately 
not a fair-share owner.
+  private long legacyUsedMemoryInBytes;
 
   SubscriptionMemoryManager() {
     memoryBlock =
@@ -46,44 +64,321 @@ public class SubscriptionMemoryManager {
             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.
-   */
+  /** Registers a queue for fair-share accounting. */
+  public synchronized MemoryHandle registerQueue() {
+    final MemoryHandle handle = new MemoryHandle(++nextHandleId);
+    handles.put(handle.id, handle);
+    return handle;
+  }
+
+  /** Reserves memory through the compatibility owner. New queues should use a 
handle. */
   public synchronized boolean tryAllocate(final long sizeInBytes) {
+    return tryAllocateLegacy(sizeInBytes).isAccepted();
+  }
+
+  private AllocationResult tryAllocateLegacy(final long sizeInBytes) {
     if (sizeInBytes <= 0L) {
-      return true;
+      return AllocationResult.accepted(getTotalMemorySizeInBytes(), 
getFreeMemorySizeInBytes());
     }
-    if (memoryBlock.allocate(sizeInBytes)) {
-      return true;
+    final long total = getTotalMemorySizeInBytes();
+    final long used = getUsedMemorySizeInBytes();
+    if (total > 0L && used <= total && sizeInBytes <= total - used) {
+      if (!memoryBlock.allocate(sizeInBytes)) {
+        return AllocationResult.rejected(
+            AllocationRejectionReason.MEMORY_LIMIT, total, 
getFreeMemorySizeInBytes());
+      }
+      legacyUsedMemoryInBytes += sizeInBytes;
+      return AllocationResult.accepted(total, total - used - sizeInBytes);
     }
-    if (memoryBlock.getTotalMemorySizeInBytes() > 0L
-        && memoryBlock.getUsedMemoryInBytes() == 0L
-        && sizeInBytes > memoryBlock.getTotalMemorySizeInBytes()) {
+    if (total > 0L && used == 0L && sizeInBytes > total) {
       memoryBlock.forceAllocateWithoutLimitation(sizeInBytes);
-      return true;
+      legacyUsedMemoryInBytes += sizeInBytes;
+      return AllocationResult.accepted(total, total - sizeInBytes);
     }
-    return false;
+    return AllocationResult.rejected(
+        sizeInBytes > total
+            ? AllocationRejectionReason.OVERSIZED_ENTRY
+            : AllocationRejectionReason.MEMORY_LIMIT,
+        total,
+        total - used);
   }
 
   public synchronized void release(final long sizeInBytes) {
-    if (sizeInBytes > 0L) {
-      memoryBlock.release(sizeInBytes);
+    if (sizeInBytes <= 0L) {
+      return;
+    }
+    final long released = Math.min(sizeInBytes, legacyUsedMemoryInBytes);
+    if (released > 0L) {
+      legacyUsedMemoryInBytes -= released;
+      memoryBlock.release(released);
     }
   }
 
-  public long getTotalMemorySizeInBytes() {
+  public synchronized long getTotalMemorySizeInBytes() {
     return memoryBlock.getTotalMemorySizeInBytes();
   }
 
-  public long getUsedMemorySizeInBytes() {
+  public synchronized long getUsedMemorySizeInBytes() {
     return memoryBlock.getUsedMemoryInBytes();
   }
 
-  public long getFreeMemorySizeInBytes() {
+  public synchronized long getFreeMemorySizeInBytes() {
     return memoryBlock.getFreeMemoryInBytes();
   }
+
+  private synchronized AllocationResult tryAllocate(
+      final MemoryHandle handle, final long sizeInBytes) {
+    final AllocationResult decision = inspectAllocationUnderLock(handle, 
sizeInBytes);
+    if (!decision.isAccepted() || sizeInBytes <= 0L) {
+      return decision;
+    }
+
+    if (!memoryBlock.allocate(sizeInBytes)) {
+      return AllocationResult.rejected(
+          AllocationRejectionReason.MEMORY_LIMIT,
+          handle.getMemoryQuotaInBytesUnderLock(),
+          getFreeMemorySizeInBytes());
+    }
+    handle.usedMemoryInBytes += sizeInBytes;
+    return AllocationResult.accepted(
+        handle.getMemoryQuotaInBytesUnderLock(), 
handle.getFreeMemorySizeInBytes());
+  }
+
+  private AllocationResult inspectAllocationUnderLock(
+      final MemoryHandle handle, final long sizeInBytes) {
+    if (sizeInBytes <= 0L) {
+      return AllocationResult.accepted(
+          handle.getMemoryQuotaInBytes(), handle.getFreeMemorySizeInBytes());
+    }
+    if (!handle.active || handle.closed || !handles.containsKey(handle.id)) {
+      return AllocationResult.rejected(
+          AllocationRejectionReason.MEMORY_LIMIT, getTotalMemorySizeInBytes(), 
0L);
+    }
+
+    final long total = getTotalMemorySizeInBytes();
+    final long used = getUsedMemorySizeInBytes();
+    final long quota = handle.getMemoryQuotaInBytesUnderLock();
+    final long protectedShare = quota / PROTECTED_SHARE_DIVISOR;
+    final long maximum = handle.getMaximumMemorySizeInBytesUnderLock();
+    long reservedByOthers = 0L;
+    for (final MemoryHandle other : handles.values()) {
+      if (other != handle && !other.closed && (other.active || 
other.usedMemoryInBytes > 0L)) {
+        reservedByOthers += Math.max(protectedShare, other.usedMemoryInBytes);
+      }
+    }
+    final long availableToOwner = Math.max(0L, total - legacyUsedMemoryInBytes 
- reservedByOthers);
+    final long ownerUsed = handle.usedMemoryInBytes;
+    final boolean fitsInNode = total > 0L && used <= total && sizeInBytes <= 
total - used;
+    final boolean fitsInShare =
+        ownerUsed <= availableToOwner && sizeInBytes <= availableToOwner - 
ownerUsed;
+
+    if (fitsInNode && fitsInShare) {
+      return AllocationResult.accepted(quota, availableToOwner - ownerUsed);
+    }
+
+    final AllocationRejectionReason reason;
+    if (sizeInBytes > maximum) {
+      reason = AllocationRejectionReason.OVERSIZED_ENTRY;
+    } else if (ownerUsed > maximum || sizeInBytes > maximum - ownerUsed) {
+      reason = AllocationRejectionReason.MEMORY_QUOTA;
+    } else {
+      reason = AllocationRejectionReason.MEMORY_LIMIT;
+    }
+    return AllocationResult.rejected(
+        reason, quota, Math.min(availableToOwner - ownerUsed, total - used));
+  }
+
+  private synchronized void release(final MemoryHandle handle, final long 
sizeInBytes) {
+    if (sizeInBytes <= 0L || handle.closed) {
+      return;
+    }
+    final long released = Math.min(sizeInBytes, handle.usedMemoryInBytes);
+    if (released > 0L) {
+      handle.usedMemoryInBytes -= released;
+      memoryBlock.release(released);
+    }
+  }
+
+  private synchronized void unregister(final MemoryHandle handle) {
+    if (handle.closed) {
+      return;
+    }
+    final long remaining = handle.usedMemoryInBytes;
+    if (remaining > 0L) {
+      memoryBlock.release(remaining);
+      handle.usedMemoryInBytes = 0L;
+    }
+    handles.remove(handle.id);
+    handle.closed = true;
+  }
+
+  private synchronized int activeHandleCountUnderLock() {
+    int count = 0;
+    for (final MemoryHandle handle : handles.values()) {
+      // An inactive queue keeps its share until lifecycle cleanup releases 
its allocations.
+      if (!handle.closed && (handle.active || handle.usedMemoryInBytes > 0L)) {
+        count++;
+      }
+    }
+    return Math.max(1, count);
+  }
+
+  public enum AllocationRejectionReason {
+    NONE("NONE"),
+    MEMORY_QUOTA("SUBSCRIPTION_MEMORY_QUOTA"),
+    MEMORY_LIMIT("SUBSCRIPTION_MEMORY_LIMIT"),
+    OVERSIZED_ENTRY("SUBSCRIPTION_OVERSIZED_ENTRY");
+
+    private final String code;
+
+    AllocationRejectionReason(final String code) {
+      this.code = code;
+    }
+
+    public String getCode() {
+      return code;
+    }
+  }
+
+  public static final class AllocationResult {
+    private final boolean accepted;
+    private final AllocationRejectionReason rejectionReason;
+    private final long quotaInBytes;
+    private final long freeMemoryInBytes;
+
+    private AllocationResult(
+        final boolean accepted,
+        final AllocationRejectionReason rejectionReason,
+        final long quotaInBytes,
+        final long freeMemoryInBytes) {
+      this.accepted = accepted;
+      this.rejectionReason = rejectionReason;
+      this.quotaInBytes = quotaInBytes;
+      this.freeMemoryInBytes = freeMemoryInBytes;
+    }
+
+    private static AllocationResult accepted(
+        final long quotaInBytes, final long freeMemoryInBytes) {
+      return new AllocationResult(
+          true, AllocationRejectionReason.NONE, quotaInBytes, 
freeMemoryInBytes);
+    }
+
+    private static AllocationResult rejected(
+        final AllocationRejectionReason reason,
+        final long quotaInBytes,
+        final long freeMemoryInBytes) {
+      return new AllocationResult(false, reason, quotaInBytes, 
freeMemoryInBytes);
+    }
+
+    public boolean isAccepted() {
+      return accepted;
+    }
+
+    public AllocationRejectionReason getRejectionReason() {
+      return rejectionReason;
+    }
+
+    public long getQuotaInBytes() {
+      return quotaInBytes;
+    }
+
+    public long getFreeMemoryInBytes() {
+      return freeMemoryInBytes;
+    }
+  }
+
+  /** Per-queue view of the shared subscription memory budget. */
+  public final class MemoryHandle implements AutoCloseable {
+    private final long id;
+    private long usedMemoryInBytes;
+    private boolean active = true;
+    private boolean closed;
+
+    private MemoryHandle(final long id) {
+      this.id = id;
+    }
+
+    public AllocationResult tryAllocate(final long sizeInBytes) {
+      return SubscriptionMemoryManager.this.tryAllocate(this, sizeInBytes);
+    }
+
+    public void release(final long sizeInBytes) {
+      SubscriptionMemoryManager.this.release(this, sizeInBytes);
+    }
+
+    public long getUsedMemorySizeInBytes() {
+      synchronized (SubscriptionMemoryManager.this) {
+        return usedMemoryInBytes;
+      }
+    }
+
+    public long getMemoryQuotaInBytes() {
+      synchronized (SubscriptionMemoryManager.this) {
+        return getMemoryQuotaInBytesUnderLock();
+      }
+    }
+
+    private long getMemoryQuotaInBytesUnderLock() {
+      return Math.max(0L, getTotalMemorySizeInBytes() - 
legacyUsedMemoryInBytes)
+          / activeHandleCountUnderLock();
+    }
+
+    public long getMaximumMemorySizeInBytes() {
+      synchronized (SubscriptionMemoryManager.this) {
+        return getMaximumMemorySizeInBytesUnderLock();
+      }
+    }
+
+    private long getMaximumMemorySizeInBytesUnderLock() {
+      final long budget = Math.max(0L, getTotalMemorySizeInBytes() - 
legacyUsedMemoryInBytes);
+      final long protectedShare = getMemoryQuotaInBytesUnderLock() / 
PROTECTED_SHARE_DIVISOR;
+      return Math.max(0L, budget - protectedShare * 
(activeHandleCountUnderLock() - 1L));
+    }
+
+    public long getFreeMemorySizeInBytes() {
+      synchronized (SubscriptionMemoryManager.this) {
+        // Probe the size of one byte to account for protected shares and live 
borrowed allocations.
+        return Math.max(
+            0L,
+            SubscriptionMemoryManager.this
+                .inspectAllocationUnderLock(this, 1L)
+                .getFreeMemoryInBytes());
+      }
+    }
+
+    public boolean isActive() {
+      synchronized (SubscriptionMemoryManager.this) {
+        return active && !closed;
+      }
+    }
+
+    public void setActive(final boolean active) {
+      synchronized (SubscriptionMemoryManager.this) {
+        if (!closed) {
+          this.active = active;
+        }
+      }
+    }
+
+    public boolean canAllocate(final long sizeInBytes) {
+      synchronized (SubscriptionMemoryManager.this) {
+        return SubscriptionMemoryManager.this
+            .inspectAllocationUnderLock(this, sizeInBytes)
+            .isAccepted();
+      }
+    }
+
+    public AllocationRejectionReason inspectRejection(final long sizeInBytes) {
+      synchronized (SubscriptionMemoryManager.this) {
+        return SubscriptionMemoryManager.this
+            .inspectAllocationUnderLock(this, sizeInBytes)
+            .getRejectionReason();
+      }
+    }
+
+    @Override
+    public void close() {
+      SubscriptionMemoryManager.this.unregister(this);
+    }
+  }
 }
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
index 60f30834cad..c7ebf06ab05 100644
--- 
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
@@ -26,12 +26,15 @@ 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.consensus.iot.subscription.SubscriptionQueueAdmission;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 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.ErrorPayload;
 import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
 
 import org.apache.tsfile.enums.ColumnCategory;
@@ -65,6 +68,192 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
 
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+  @Test
+  public void 
testLargeQueueQuotaDoesNotBlockSmallQueueAndPreservesPendingEntries()
+      throws Exception {
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalBatchMaxDelay =
+        
CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusBatchMaxDelayInMs();
+    final int originalMaxWalEntries =
+        
CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusBatchMaxWalEntries();
+    ConsensusPrefetchingQueue largeQueue = null;
+    ConsensusPrefetchingQueue smallQueue = null;
+    try {
+      
CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxDelayInMs(0);
+      
CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxWalEntries(1);
+      final Tablet largeTablet = createLargeTablet();
+      final long largeBytes = 
PipeMemoryWeightUtil.calculateTabletSizeInBytes(largeTablet);
+      final long smallBytes = 
PipeMemoryWeightUtil.calculateTabletSizeInBytes(createTablet());
+      final SubscriptionMemoryManager memoryManager =
+          // With two queues, a queue may use 3/4 of the budget; make that 
exactly one large entry.
+          new SubscriptionMemoryManager(largeBytes + largeBytes / 3L);
+      final ConsensusSubscriptionCommitManager commitManager =
+          
newCommitManager(temporaryFolder.newFolder("large-small-fair-share"));
+      final FakeConsensusReqReader largeReader = new FakeConsensusReqReader();
+      final FakeConsensusReqReader smallReader = new FakeConsensusReqReader();
+      final AtomicInteger largeConversions = new AtomicInteger();
+      final AtomicInteger smallConversions = new AtomicInteger();
+      largeQueue =
+          newQueue(
+              "largeGroup",
+              new DataRegionId(1),
+              largeReader,
+              newConverter(largeConversions, largeTablet),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      smallQueue =
+          newQueue(
+              "smallGroup",
+              new DataRegionId(2),
+              smallReader,
+              newConverter(smallConversions),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      largeQueue.setSubscriptionMemoryManager(memoryManager);
+      smallQueue.setSubscriptionMemoryManager(memoryManager);
+      assertNull(largeQueue.poll("largeConsumer"));
+      assertNull(smallQueue.poll("smallConsumer"));
+
+      largeReader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(largeQueue).offer(createRequest(1L)));
+      assertTrue(pendingEntries(largeQueue).offer(createRequest(2L)));
+      largeQueue.drivePrefetchOnce();
+      assertEquals(largeBytes, largeQueue.getRetainedTabletBytes());
+      final long retainedPendingBytes = largeQueue.getRetainedRequestBytes();
+      assertTrue(retainedPendingBytes > 0L);
+      largeQueue.drivePrefetchOnce();
+      assertEquals(1, largeConversions.get());
+      assertEquals(1, pendingEntries(largeQueue).size());
+      assertEquals(retainedPendingBytes, largeQueue.getRetainedRequestBytes());
+      assertFalse(pendingEntries(largeQueue).offer(createRequest(3L)));
+      assertEquals(
+          SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA,
+          ((SubscriptionQueueAdmission) 
pendingEntries(largeQueue)).getLastRejectionReason());
+      assertEquals(1L, largeQueue.getRealtimeAdmissionRejectionCount());
+      assertEquals(
+          1L,
+          largeQueue.getRealtimeAdmissionRejectionCount(
+              SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA));
+
+      smallReader.currentSearchIndex = 1L;
+      assertTrue(pendingEntries(smallQueue).offer(createRequest(1L)));
+      smallQueue.drivePrefetchOnce();
+      assertEquals(1, smallConversions.get());
+      assertEquals(largeBytes + smallBytes, 
memoryManager.getUsedMemorySizeInBytes());
+      final SubscriptionEvent smallEvent = smallQueue.poll("smallConsumer");
+      assertNotNull(smallEvent);
+      assertTrue(smallQueue.ack("smallConsumer", 
smallEvent.getCommitContext()));
+      assertEquals(largeBytes, memoryManager.getUsedMemorySizeInBytes());
+
+      final SubscriptionEvent largeEvent = largeQueue.poll("largeConsumer");
+      assertNotNull(largeEvent);
+      assertTrue(largeQueue.ack("largeConsumer", 
largeEvent.getCommitContext()));
+      largeQueue.drivePrefetchOnce();
+      assertEquals(2, largeConversions.get());
+      assertEquals(0, pendingEntries(largeQueue).size());
+      assertEquals(0L, largeQueue.getRetainedRequestBytes());
+      final SubscriptionEvent secondLargeEvent = 
largeQueue.poll("largeConsumer");
+      assertNotNull(secondLargeEvent);
+      assertTrue(largeQueue.ack("largeConsumer", 
secondLargeEvent.getCommitContext()));
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (largeQueue != null) {
+        largeQueue.close();
+      }
+      if (smallQueue != null) {
+        smallQueue.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionConsensusBatchMaxWalEntries(originalMaxWalEntries);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testOversizedEntryReportsErrorWhileOtherQueueHoldsMemory() 
throws Exception {
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalBatchMaxDelay =
+        
CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusBatchMaxDelayInMs();
+    ConsensusPrefetchingQueue largeQueue = null;
+    ConsensusPrefetchingQueue smallQueue = null;
+    try {
+      
CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxDelayInMs(0);
+      final Tablet largeTablet = createLargeTablet();
+      final long largeBytes = 
PipeMemoryWeightUtil.calculateTabletSizeInBytes(largeTablet);
+      // This entry exceeds the entire node budget, which is also partly held 
by another queue.
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(largeBytes / 2L);
+      final ConsensusSubscriptionCommitManager commitManager =
+          newCommitManager(temporaryFolder.newFolder("oversized-queue-error"));
+      final FakeConsensusReqReader largeReader = new FakeConsensusReqReader();
+      final FakeConsensusReqReader smallReader = new FakeConsensusReqReader();
+      final AtomicInteger largeConversions = new AtomicInteger();
+      largeQueue =
+          newQueue(
+              "largeGroup",
+              new DataRegionId(1),
+              largeReader,
+              newConverter(largeConversions, largeTablet),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      smallQueue =
+          newQueue(
+              "smallGroup",
+              new DataRegionId(2),
+              smallReader,
+              newConverter(new AtomicInteger()),
+              commitManager,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE);
+      largeQueue.setSubscriptionMemoryManager(memoryManager);
+      smallQueue.setSubscriptionMemoryManager(memoryManager);
+      assertNull(largeQueue.poll("largeConsumer"));
+      assertNull(smallQueue.poll("smallConsumer"));
+      smallReader.currentSearchIndex = 1L;
+      assertTrue(pendingEntries(smallQueue).offer(createRequest(1L)));
+      smallQueue.drivePrefetchOnce();
+      final long smallBytes = memoryManager.getUsedMemorySizeInBytes();
+      assertTrue(smallBytes > 0L);
+
+      largeReader.currentSearchIndex = 1L;
+      assertTrue(pendingEntries(largeQueue).offer(createRequest(1L)));
+      largeQueue.drivePrefetchOnce();
+      assertEquals(1, largeConversions.get());
+      assertEquals(1L, largeQueue.getCurrentReadSearchIndex());
+      assertEquals(1L, largeQueue.getOversizedEntryRejectionCount());
+      assertEquals(0L, largeQueue.getRealtimeAdmissionRejectionCount());
+      assertEquals(smallBytes, memoryManager.getUsedMemorySizeInBytes());
+      final SubscriptionEvent error = largeQueue.poll("largeConsumer");
+      assertNotNull(error);
+      final ErrorPayload payload = (ErrorPayload) 
error.getCurrentResponse().getPayload();
+      assertTrue(payload.isCritical());
+      
assertTrue(payload.getErrorMessage().contains("SUBSCRIPTION_OVERSIZED_ENTRY"));
+      for (int i = 0; i < 10; i++) {
+        largeQueue.drivePrefetchOnce();
+      }
+      assertEquals(1, largeConversions.get());
+      assertEquals(1L, largeQueue.getCurrentReadSearchIndex());
+      final SubscriptionEvent smallEvent = smallQueue.poll("smallConsumer");
+      assertNotNull(smallEvent);
+      assertTrue(smallQueue.ack("smallConsumer", 
smallEvent.getCommitContext()));
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+    } finally {
+      if (largeQueue != null) {
+        largeQueue.close();
+      }
+      if (smallQueue != null) {
+        smallQueue.close();
+      }
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
   @Test
   public void testQueuesShareDataNodeMemoryBudget() throws Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
@@ -77,7 +266,8 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
       
CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusBatchMaxDelayInMs(0);
       final Tablet tablet = createTablet();
       final long oneTabletBytes = 
PipeMemoryWeightUtil.calculateTabletSizeInBytes(tablet);
-      final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(oneTabletBytes);
+      final SubscriptionMemoryManager memoryManager =
+          new SubscriptionMemoryManager(oneTabletBytes * 2L);
       final ConsensusSubscriptionCommitManager commitManager = 
newCommitManager(systemDir);
 
       final FakeConsensusReqReader readerA = new FakeConsensusReqReader();
@@ -121,34 +311,28 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
               <= memoryManager.getTotalMemorySizeInBytes());
 
       readerB.currentSearchIndex = 1L;
-      assertFalse(pendingEntries(queueB).offer(createRequest(1L)));
+      assertTrue(pendingEntries(queueB).offer(createRequest(1L)));
       queueB.drivePrefetchOnce();
-      assertEquals(0, conversionCountB.get());
-      assertEquals("true", 
queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
-      assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
+      assertEquals(1, conversionCountB.get());
+      assertEquals("false", 
queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertEquals(oneTabletBytes * 2L, 
memoryManager.getUsedMemorySizeInBytes());
+      assertEquals(oneTabletBytes, queueB.getRetainedTabletBytes());
 
       final SubscriptionEvent eventA = queueA.poll("consumerA");
       assertNotNull(eventA);
       assertTrue(queueA.ack("consumerA", eventA.getCommitContext()));
       assertEquals(0L, queueA.getRetainedTabletBytes());
-      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+      assertEquals(oneTabletBytes, 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());
+      queueB.drivePrefetchOnce();
+      assertEquals("false", 
queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
     } finally {
       if (queueA != null) {
         queueA.close();
@@ -203,23 +387,20 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
       assertEquals(1, conversionCount.get());
       assertEquals(2L, queue.getCurrentReadSearchIndex());
       assertEquals("1", 
queue.coreReportMessage().get("bufferedRealtimeEntryCount"));
-      assertEquals("0", queue.coreReportMessage().get("pendingEntriesSize"));
+      assertEquals("1", queue.coreReportMessage().get("pendingEntriesSize"));
       assertEquals("true", 
queue.coreReportMessage().get("realtimeAdmissionBlocked"));
+      assertFalse(pendingEntries(queue).offer(createRequest(3L)));
+      assertEquals("SUBSCRIPTION_WRITER_BACKLOG", 
queue.getLastAdmissionRejectionCode());
+      assertEquals(0L, queue.getSubscriptionMemoryRejectionCount());
 
       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"));
+      assertEquals("false", 
queue.coreReportMessage().get("realtimeAdmissionBlocked"));
     } finally {
       if (queue != null) {
         queue.close();
@@ -260,12 +441,17 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
   }
 
   private static ConsensusLogToTabletConverter newConverter(final 
AtomicInteger conversionCount) {
+    return newConverter(conversionCount, createTablet());
+  }
+
+  private static ConsensusLogToTabletConverter newConverter(
+      final AtomicInteger conversionCount, final Tablet tablet) {
     final ConsensusLogToTabletConverter converter = 
mock(ConsensusLogToTabletConverter.class);
     when(converter.convert(any()))
         .thenAnswer(
             ignored -> {
               conversionCount.incrementAndGet();
-              return Collections.singletonList(createTablet());
+              return Collections.singletonList(tablet);
             });
     when(converter.getDatabaseName()).thenReturn("db");
     return converter;
@@ -291,6 +477,21 @@ public class ConsensusPrefetchingQueueDataNodeMemoryTest {
     return tablet;
   }
 
+  private static Tablet createLargeTablet() {
+    final Tablet tablet =
+        new Tablet(
+            "sensors",
+            Arrays.asList("device", "payload"),
+            Arrays.asList(TSDataType.STRING, TSDataType.STRING),
+            Arrays.asList(ColumnCategory.TAG, ColumnCategory.FIELD),
+            1);
+    tablet.addTimestamp(0, 1L);
+    tablet.addValue(0, 0, "d1");
+    tablet.addValue(0, 1, "v".repeat(20_000));
+    tablet.setRowSize(1);
+    return tablet;
+  }
+
   private static IndexedConsensusRequest createRequest(final long searchIndex) 
{
     return new IndexedConsensusRequest(
             searchIndex,
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 190a21cca3f..2b3dc9f0ef5 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
@@ -30,6 +30,8 @@ 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.consensus.iot.logdispatcher.IoTConsensusMemoryManager;
+import org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueAdmission;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
 import org.apache.iotdb.db.storageengine.dataregion.wal.io.ProgressWALReader;
@@ -81,8 +83,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
-import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
 import java.util.function.LongSupplier;
+import java.util.function.Supplier;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -636,7 +639,8 @@ public class ConsensusPrefetchingQueueTest {
     final Class<?> queueClass =
         Class.forName(ConsensusPrefetchingQueue.class.getName() + 
"$WakeableIndexedConsensusQueue");
     final Constructor<?> constructor =
-        queueClass.getDeclaredConstructor(int.class, Runnable.class, 
BooleanSupplier.class);
+        queueClass.getDeclaredConstructor(
+            int.class, Runnable.class, Supplier.class, Consumer.class);
     constructor.setAccessible(true);
 
     final AtomicBoolean admissionEnabled = new AtomicBoolean(true);
@@ -646,7 +650,7 @@ public class ConsensusPrefetchingQueueTest {
     final CountDownLatch clearCompleted = new CountDownLatch(1);
     final AtomicReference<Boolean> offered = new AtomicReference<>();
     final AtomicReference<Throwable> asyncFailure = new AtomicReference<>();
-    final BooleanSupplier admissionSupplier =
+    final Supplier<SubscriptionQueueRejectionReason> admissionSupplier =
         () -> {
           final boolean admitted = admissionEnabled.get();
           admissionChecked.countDown();
@@ -658,11 +662,17 @@ public class ConsensusPrefetchingQueueTest {
             Thread.currentThread().interrupt();
             throw new AssertionError(e);
           }
-          return admitted;
+          return admitted
+              ? SubscriptionQueueRejectionReason.NONE
+              : SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED;
         };
     final BlockingQueue<IndexedConsensusRequest> queue =
         (BlockingQueue<IndexedConsensusRequest>)
-            constructor.newInstance(8, (Runnable) () -> {}, admissionSupplier);
+            constructor.newInstance(
+                8,
+                (Runnable) () -> {},
+                admissionSupplier,
+                (Consumer<SubscriptionQueueRejectionReason>) reason -> {});
 
     final Thread offerThread =
         new Thread(
@@ -715,6 +725,77 @@ public class ConsensusPrefetchingQueueTest {
     }
     assertTrue(Boolean.TRUE.equals(offered.get()));
     assertTrue(queue.isEmpty());
+    assertFalse(queue.offer(createRequest(2L)));
+    assertEquals(
+        SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED,
+        ((SubscriptionQueueAdmission) queue).getLastRejectionReason());
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testConcurrentOffersKeepTheirOwnRejectionReasons() throws 
Exception {
+    final Class<?> queueClass =
+        Class.forName(ConsensusPrefetchingQueue.class.getName() + 
"$WakeableIndexedConsensusQueue");
+    final Constructor<?> constructor =
+        queueClass.getDeclaredConstructor(
+            int.class, Runnable.class, Supplier.class, Consumer.class);
+    constructor.setAccessible(true);
+    final BlockingQueue<IndexedConsensusRequest> queue =
+        (BlockingQueue<IndexedConsensusRequest>)
+            constructor.newInstance(
+                8,
+                (Runnable) () -> {},
+                (Supplier<SubscriptionQueueRejectionReason>)
+                    () ->
+                        Thread.currentThread().getName().equals("first-offer")
+                            ? 
SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA
+                            : SubscriptionQueueRejectionReason.WRITER_BACKLOG,
+                (Consumer<SubscriptionQueueRejectionReason>) reason -> {});
+    final CountDownLatch firstOffered = new CountDownLatch(1);
+    final CountDownLatch secondOffered = new CountDownLatch(1);
+    final AtomicReference<SubscriptionQueueRejectionReason> firstReason = new 
AtomicReference<>();
+    final AtomicReference<SubscriptionQueueRejectionReason> secondReason = new 
AtomicReference<>();
+    final AtomicReference<Throwable> asyncFailure = new AtomicReference<>();
+    final Thread first =
+        new Thread(
+            () -> {
+              try {
+                assertFalse(queue.offer(createRequest(1L)));
+                firstOffered.countDown();
+                assertTrue(secondOffered.await(5, TimeUnit.SECONDS));
+                firstReason.set(((SubscriptionQueueAdmission) 
queue).getLastRejectionReason());
+              } catch (final Throwable t) {
+                asyncFailure.compareAndSet(null, t);
+              }
+            },
+            "first-offer");
+    final Thread second =
+        new Thread(
+            () -> {
+              try {
+                assertTrue(firstOffered.await(5, TimeUnit.SECONDS));
+                assertFalse(queue.offer(createRequest(2L)));
+                secondReason.set(((SubscriptionQueueAdmission) 
queue).getLastRejectionReason());
+              } catch (final Throwable t) {
+                asyncFailure.compareAndSet(null, t);
+              } finally {
+                secondOffered.countDown();
+              }
+            },
+            "second-offer");
+    first.setDaemon(true);
+    second.setDaemon(true);
+    first.start();
+    second.start();
+    first.join(TimeUnit.SECONDS.toMillis(5));
+    second.join(TimeUnit.SECONDS.toMillis(5));
+    assertFalse(first.isAlive());
+    assertFalse(second.isAlive());
+    if (asyncFailure.get() != null) {
+      throw new AssertionError(asyncFailure.get());
+    }
+    assertEquals(SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA, 
firstReason.get());
+    assertEquals(SubscriptionQueueRejectionReason.WRITER_BACKLOG, 
secondReason.get());
   }
 
   @Test
@@ -723,7 +804,8 @@ public class ConsensusPrefetchingQueueTest {
     final Class<?> queueClass =
         Class.forName(ConsensusPrefetchingQueue.class.getName() + 
"$WakeableIndexedConsensusQueue");
     final Constructor<?> constructor =
-        queueClass.getDeclaredConstructor(int.class, Runnable.class, 
BooleanSupplier.class);
+        queueClass.getDeclaredConstructor(
+            int.class, Runnable.class, Supplier.class, Consumer.class);
     constructor.setAccessible(true);
     final Method retainedBytesMethod = 
queueClass.getDeclaredMethod("getRetainedRequestBytes");
     retainedBytesMethod.setAccessible(true);
@@ -735,10 +817,20 @@ public class ConsensusPrefetchingQueueTest {
         new AtomicLongMemoryBlock("SubscriptionPendingTest", null, 100L);
     final BlockingQueue<IndexedConsensusRequest> firstQueue =
         (BlockingQueue<IndexedConsensusRequest>)
-            constructor.newInstance(8, (Runnable) () -> {}, (BooleanSupplier) 
() -> true);
+            constructor.newInstance(
+                8,
+                (Runnable) () -> {},
+                (Supplier<SubscriptionQueueRejectionReason>)
+                    () -> SubscriptionQueueRejectionReason.NONE,
+                (Consumer<SubscriptionQueueRejectionReason>) reason -> {});
     final BlockingQueue<IndexedConsensusRequest> secondQueue =
         (BlockingQueue<IndexedConsensusRequest>)
-            constructor.newInstance(8, (Runnable) () -> {}, (BooleanSupplier) 
() -> true);
+            constructor.newInstance(
+                8,
+                (Runnable) () -> {},
+                (Supplier<SubscriptionQueueRejectionReason>)
+                    () -> SubscriptionQueueRejectionReason.NONE,
+                (Consumer<SubscriptionQueueRejectionReason>) reason -> {});
 
     memoryManager.init(testMemoryBlock, 0.6);
     try {
@@ -750,6 +842,9 @@ public class ConsensusPrefetchingQueueTest {
       assertEquals(40L, retainedBytesMethod.invoke(secondQueue));
 
       assertFalse(firstQueue.offer(createSizedRequest(2L, 10L, 30)));
+      assertEquals(
+          SubscriptionQueueRejectionReason.CONSENSUS_REQUEST_MEMORY_LIMIT,
+          ((SubscriptionQueueAdmission) firstQueue).getLastRejectionReason());
       assertEquals(40L, testMemoryBlock.getUsedMemoryInBytes());
 
       firstQueue.clear();
@@ -989,6 +1084,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -1809,6 +1905,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -1877,6 +1974,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -2685,6 +2783,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       reader.currentSearchIndex = 2L;
       assertTrue(pendingEntries(queue).offer(createRequest(1L)));
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
index b3783beab43..e25c77803cf 100644
--- 
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
@@ -154,7 +154,7 @@ public class ConsensusPrefetchingQueueWalBackpressureTest {
       assertEquals(Arrays.asList(1L, 2L), conversionAttempts);
       assertEquals(2L, queue.getCurrentReadSearchIndex());
       assertEquals(1, queue.getPrefetchedEventCount());
-      assertTrue(pendingEntries(queue).isEmpty());
+      assertEquals(0, pendingEntries(queue).size());
       assertEquals("true", 
queue.coreReportMessage().get("realtimeAdmissionBlocked"));
       assertEquals(oneTabletBytes, memoryManager.getUsedMemorySizeInBytes());
       assertMemoryBounded(queue, memoryManager);
@@ -307,22 +307,11 @@ public class ConsensusPrefetchingQueueWalBackpressureTest 
{
     }
 
     @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();
+    public synchronized long getUsedMemorySizeInBytes() {
+      final long usedMemorySizeInBytes = super.getUsedMemorySizeInBytes();
       maximumUsedMemorySizeInBytes = Math.max(maximumUsedMemorySizeInBytes, 
usedMemorySizeInBytes);
       exceededBudget |= usedMemorySizeInBytes > getTotalMemorySizeInBytes();
+      return usedMemorySizeInBytes;
     }
 
     private long getMaximumUsedMemorySizeInBytes() {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
index 08880f4fcb3..782cebe30cd 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
@@ -22,6 +22,7 @@ package org.apache.iotdb.db.subscription.metric;
 import org.apache.iotdb.commons.consensus.DataRegionId;
 import org.apache.iotdb.commons.service.metric.enums.Metric;
 import org.apache.iotdb.commons.service.metric.enums.Tag;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 import 
org.apache.iotdb.db.subscription.broker.consensus.ConsensusPrefetchingQueue;
 import org.apache.iotdb.metrics.AbstractMetricService;
 import org.apache.iotdb.metrics.type.Rate;
@@ -132,6 +133,18 @@ public class 
ConsensusSubscriptionPrefetchingQueueMetricsTest {
               eq(queueId),
               eq(Tag.REGION.toString()),
               eq(firstRegionId.toString()));
+      verify(metricService)
+          .createAutoGauge(
+              
eq(Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS_BY_REASON.toString()),
+              eq(MetricLevel.IMPORTANT),
+              eq(firstQueue),
+              any(),
+              eq(Tag.NAME.toString()),
+              eq(queueId),
+              eq(Tag.REGION.toString()),
+              eq(firstRegionId.toString()),
+              eq(Tag.REASON.toString()),
+              
eq(SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA.getCode()));
       verify(metricService)
           .createAutoGauge(
               eq(Metric.SUBSCRIPTION_CONSENSUS_LAG.toString()),
@@ -164,6 +177,16 @@ public class 
ConsensusSubscriptionPrefetchingQueueMetricsTest {
               queueId,
               Tag.REGION.toString(),
               firstRegionId.toString());
+      verify(metricService)
+          .remove(
+              MetricType.AUTO_GAUGE,
+              
Metric.SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS_BY_REASON.toString(),
+              Tag.NAME.toString(),
+              queueId,
+              Tag.REGION.toString(),
+              firstRegionId.toString(),
+              Tag.REASON.toString(),
+              
SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA.getCode());
       verify(metricService, never())
           .remove(
               MetricType.AUTO_GAUGE,
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
index 1635a6e0ecf..beda24e623b 100644
--- 
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
@@ -61,4 +61,112 @@ public class SubscriptionMemoryManagerTest {
     assertFalse(memoryManager.tryAllocate(1L));
     assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
   }
+
+  @Test
+  public void 
testQueueCanBorrowIdleMemoryWithoutConsumingAnotherQueuesProtectedShare() {
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(10L);
+    final SubscriptionMemoryManager.MemoryHandle queueA = 
memoryManager.registerQueue();
+    final SubscriptionMemoryManager.MemoryHandle queueB = 
memoryManager.registerQueue();
+
+    assertEquals(5L, queueA.getMemoryQuotaInBytes());
+    assertEquals(
+        SubscriptionMemoryManager.AllocationRejectionReason.OVERSIZED_ENTRY,
+        queueA.inspectRejection(9L));
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+
+    assertTrue(queueA.tryAllocate(6L).isAccepted());
+    assertTrue(queueA.tryAllocate(2L).isAccepted());
+    assertEquals(
+        SubscriptionMemoryManager.AllocationRejectionReason.MEMORY_QUOTA,
+        queueA.inspectRejection(1L));
+    assertTrue(queueB.tryAllocate(2L).isAccepted());
+    assertFalse(queueB.tryAllocate(1L).isAccepted());
+    assertEquals(10L, memoryManager.getUsedMemorySizeInBytes());
+
+    queueA.close();
+    assertEquals(10L, queueB.getMemoryQuotaInBytes());
+    assertTrue(queueB.tryAllocate(8L).isAccepted());
+    assertEquals(10L, memoryManager.getUsedMemorySizeInBytes());
+
+    queueB.close();
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+  }
+
+  @Test
+  public void 
testBorrowedMemoryWaitsForAckWhenAnotherQueueNeedsMoreThanItsReserve() {
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(100L);
+    try (SubscriptionMemoryManager.MemoryHandle largeQueue = 
memoryManager.registerQueue();
+        SubscriptionMemoryManager.MemoryHandle smallQueue = 
memoryManager.registerQueue()) {
+      assertTrue(largeQueue.tryAllocate(75L).isAccepted());
+      assertEquals(25L, smallQueue.getFreeMemorySizeInBytes());
+      assertEquals(
+          SubscriptionMemoryManager.AllocationRejectionReason.MEMORY_LIMIT,
+          smallQueue.inspectRejection(30L));
+      assertTrue(smallQueue.tryAllocate(25L).isAccepted());
+      assertEquals(0L, smallQueue.getFreeMemorySizeInBytes());
+      largeQueue.release(50L);
+      assertTrue(smallQueue.tryAllocate(30L).isAccepted());
+      assertEquals(80L, memoryManager.getUsedMemorySizeInBytes());
+    }
+  }
+
+  @Test
+  public void testNewQueueWaitsForExistingBorrowedAllocationsToDrain() {
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(100L);
+    try (SubscriptionMemoryManager.MemoryHandle first = 
memoryManager.registerQueue()) {
+      assertTrue(first.tryAllocate(100L).isAccepted());
+      try (SubscriptionMemoryManager.MemoryHandle second = 
memoryManager.registerQueue()) {
+        assertEquals(
+            SubscriptionMemoryManager.AllocationRejectionReason.MEMORY_LIMIT,
+            second.inspectRejection(1L));
+        first.release(25L);
+        assertTrue(second.tryAllocate(25L).isAccepted());
+      }
+    }
+  }
+
+  @Test
+  public void testOversizedQueueEntryIsExplicitlyRejectedEvenWhenNodeIsEmpty() 
{
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(10L);
+    try (SubscriptionMemoryManager.MemoryHandle queue = 
memoryManager.registerQueue()) {
+      assertEquals(
+          SubscriptionMemoryManager.AllocationRejectionReason.OVERSIZED_ENTRY,
+          queue.tryAllocate(11L).getRejectionReason());
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+      assertTrue(queue.tryAllocate(10L).isAccepted());
+    }
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+  }
+
+  @Test
+  public void testInactiveQueueKeepsShareUntilRetainedDataIsReleased() {
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(10L);
+    try (SubscriptionMemoryManager.MemoryHandle queueA = 
memoryManager.registerQueue();
+        SubscriptionMemoryManager.MemoryHandle queueB = 
memoryManager.registerQueue()) {
+      assertTrue(queueA.tryAllocate(5L).isAccepted());
+      queueA.setActive(false);
+      assertEquals(5L, queueB.getMemoryQuotaInBytes());
+      assertTrue(queueB.tryAllocate(5L).isAccepted());
+      queueA.release(5L);
+      assertEquals(10L, queueB.getMemoryQuotaInBytes());
+      assertTrue(queueB.tryAllocate(5L).isAccepted());
+    }
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+  }
+
+  @Test
+  public void testAllocationProbeDoesNotReserveMemory() {
+    final SubscriptionMemoryManager memoryManager = new 
SubscriptionMemoryManager(10L);
+    try (SubscriptionMemoryManager.MemoryHandle queue = 
memoryManager.registerQueue()) {
+      for (int i = 0; i < 100; i++) {
+        assertTrue(queue.canAllocate(10L));
+        assertEquals(
+            SubscriptionMemoryManager.AllocationRejectionReason.NONE, 
queue.inspectRejection(10L));
+      }
+      assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+      assertTrue(queue.tryAllocate(10L).isAccepted());
+      queue.release(100L);
+      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 158a079be6b..b229f36d1fe 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
@@ -1206,6 +1206,13 @@ may_cache_nonexist_series=true
 # 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.
+# The ninth (Subscription) share controls consensus subscription 
materialization, independently of
+# subscription_cache_memory_usage_percentage. Each active queue protects half 
of its equal share;
+# the remaining idle capacity can be borrowed by another queue, but not 
revoked until ACK. Size
+# the budget for the largest materialized WAL entry * active queue count * at 
least 1.2; use a
+# larger multiplier for multiple buffered entries per queue. An entry 
exceeding the maximum a
+# queue can hold under these protected shares reports 
SUBSCRIPTION_OVERSIZED_ENTRY and does not
+# advance WAL progress. New queues may wait for existing borrowed allocations 
to drain via ACK.
 # effectiveMode: restart
 chunk_timeseriesmeta_free_memory_proportion=1:100:200:50:200:200:200:50:250
 
@@ -2153,6 +2160,28 @@ pipe_all_sinks_rate_limit_bytes_per_second=-1
 ### Subscription Consensus Configuration
 ####################
 
+# Maximum fraction of pipe non-floating memory used to cache serialized 
subscription poll responses.
+# Recommended starting value: 0.2, including large-field and large-batch 
workloads. This does not control
+# consensus subscription tablet materialization; size that budget using the 
ninth
+# chunk_timeseriesmeta_free_memory_proportion share instead. The cache may 
receive less than this
+# maximum under pipe memory pressure.
+# effectiveMode: restart
+# Datatype: double, range: [0, 1]
+subscription_cache_memory_usage_percentage=0.2
+
+# Maximum number of consensus-subscription prefetch workers. For large-field 
workloads, compare
+# 2 and 4 workers under load, checking CPU, heap and ACK latency; queues share 
the worker pool.
+# effectiveMode: restart
+# Datatype: int
+subscription_consensus_prefetch_executor_max_thread_num=2
+
+# Maximum materialized consensus-subscription batch size. This is a batching 
target, not a hard
+# limit on one WAL entry/tablet. For large-field workloads, choose a value at 
least as large as a
+# typical single tablet if batching is desired, and provide enough 
materialization memory per queue.
+# effectiveMode: restart
+# Datatype: long, Unit: byte
+subscription_consensus_batch_max_size_in_bytes=8388608
+
 # The interval at which an idle consensus-subscription queue advances its 
writer safe-time barrier by watermark.
 # effectiveMode: restart
 # Datatype: long(duration)
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
index 5bcafffaa14..d412d74d0cc 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
@@ -232,6 +232,14 @@ public enum Metric {
   
SUBSCRIPTION_CONSENSUS_LAST_POLL_TIME("subscription_consensus_last_poll_time"),
   
SUBSCRIPTION_CONSENSUS_LAST_PROGRESS_TIME("subscription_consensus_last_progress_time"),
   
SUBSCRIPTION_CONSENSUS_PROGRESS_STATUS("subscription_consensus_progress_status"),
+  SUBSCRIPTION_CONSENSUS_MEMORY_USED("subscription_consensus_memory_used"),
+  SUBSCRIPTION_CONSENSUS_MEMORY_QUOTA("subscription_consensus_memory_quota"),
+  
SUBSCRIPTION_CONSENSUS_MEMORY_MAXIMUM("subscription_consensus_memory_maximum"),
+  
SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS("subscription_consensus_admission_rejections"),
+  SUBSCRIPTION_CONSENSUS_ADMISSION_REJECTIONS_BY_REASON(
+      "subscription_consensus_admission_rejections_by_reason"),
+  
SUBSCRIPTION_CONSENSUS_MEMORY_REJECTIONS("subscription_consensus_memory_rejections"),
+  
SUBSCRIPTION_CONSENSUS_OVERSIZED_REJECTIONS("subscription_consensus_oversized_rejections"),
   // load related
   ACTIVE_LOADING_FILES_NUMBER("active_loading_files_number"),
   ACTIVE_LOADING_FILES_SIZE("active_loading_files_size"),
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Tag.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Tag.java
index 91b4307e02d..0f0aa0dadc6 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Tag.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Tag.java
@@ -23,6 +23,7 @@ public enum Tag {
   TYPE("type"),
   NAME("name"),
   REGION("region"),
+  REASON("reason"),
   STATUS("status"),
   DATABASE("database"),
   FROM("from"),

Reply via email to