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

Caideyipi pushed a commit to branch fix/subscription-fair-share-admission
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit 57a1f842a7bf226bb07698bc1ddbf35a538c99c4
Author: Caideyipi <[email protected]>
AuthorDate: Fri Oct 9 16:48:08 2026 +0800

    Isolate subscription materialization memory by queue
---
 .../zh/UserGuide/Subscription/Memory-Management.md |  91 ++++++
 .../iotdb/consensus/i18n/IoTConsensusMessages.java |   4 +
 .../iotdb/consensus/i18n/IoTConsensusMessages.java |   4 +
 .../subscription/SubscriptionQueueAdmission.java   |  25 ++
 .../subscription/SubscriptionQueueRegistry.java    |  34 ++-
 .../SubscriptionQueueRejectionReason.java          |  42 +++
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |   2 +
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |   2 +
 .../consensus/ConsensusPrefetchingQueue.java       | 240 +++++++++++++---
 ...nsensusSubscriptionPrefetchingQueueMetrics.java |  48 ++++
 .../resource/SubscriptionMemoryManager.java        | 313 +++++++++++++++++++--
 ...onsensusPrefetchingQueueDataNodeMemoryTest.java | 246 ++++++++++++++--
 .../consensus/ConsensusPrefetchingQueueTest.java   | 115 +++++++-
 ...nsensusPrefetchingQueueWalBackpressureTest.java |  19 +-
 .../resource/SubscriptionMemoryManagerTest.java    |  75 +++++
 .../conf/iotdb-system.properties.template          |  27 ++
 .../iotdb/commons/service/metric/enums/Metric.java |   5 +
 17 files changed, 1170 insertions(+), 122 deletions(-)

diff --git a/docs/zh/UserGuide/Subscription/Memory-Management.md 
b/docs/zh/UserGuide/Subscription/Memory-Management.md
new file mode 100644
index 00000000000..eab583e060c
--- /dev/null
+++ b/docs/zh/UserGuide/Subscription/Memory-Management.md
@@ -0,0 +1,91 @@
+<!--
+  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.
+-->
+
+# 大字段与大 batch 订阅的内存配置
+
+本页描述支持队列 fair-share 内存准入的版本。实际部署前,请确认所用版本包含这一行为;仅调整参数不能为旧版本增加队列隔离。
+
+## 区分物化预算与响应缓存
+
+| 参数 | 控制范围 | 调整建议 |
+| --- | --- | --- |
+| `chunk_timeseriesmeta_free_memory_proportion` 第九项 `Subscription` | 每个 
DataNode 上,consensus 订阅队列物化并保留的 tablet 内存 | 按最大 
entry、活跃队列数及缓冲深度计算预算;与其它查询内存份额一起调整 |
+| `subscription_cache_memory_usage_percentage` | 序列化 poll 响应缓存的上限,占 pipe 
non-floating 内存的比例 | 从默认 `0.2` 开始;响应频繁淘汰和重复序列化、且 pipe 内存充足时,压测比较 `0.3`、`0.4` |
+| `subscription_consensus_batch_max_size_in_bytes` | 物化后合并投递 batch 的目标大小 | 默认 
8 MiB 不能限制单条 WAL entry 的大小;需要更小的 entry 时,应减小写入 batch 或字段大小 |
+| `subscription_consensus_prefetch_executor_max_thread_num` | 同节点所有队列共用的预取线程池 
| 大字段场景可比较 2 与 4 线程,观察 CPU、堆内存、ACK 延迟和普通队列吞吐 |
+
+`subscription_cache_memory_usage_percentage` 不控制 tablet 物化预算。其上限为 `pipe 
non-floating 内存 × 配置比例`;缓存实际获得的额度可能因 pipe 
内存压力而更小。增加该比例不能解除物化额度阻塞,也不宜在未观察缓存淘汰的情况下统一调高。
+
+第九项控制的物化预算为:
+
+```text
+节点订阅物化预算 = query 内存预算 × Subscription 权重 / 九项权重总和
+```
+
+例如 `1:100:200:50:200:200:200:50:250` 中,Subscription 比例为 `250 / 1251`,约 
20%。修改任何一项都会影响其它项的实际比例。这些配置需要重启生效。
+
+## 计算物化预算
+
+计数单位是同一 DataNode 上活跃的订阅队列。每个 consumer group/topic/DataRegion 组合有独立队列;一个全量 
topic、一个 consumer group 也可能对应多条队列,增加同组 consumer 数不会使每个 consumer 单独获得一份物化额度。
+
+以 `E` 表示最大单条 WAL entry 转换得到的所有 tablet 的估算内存总和,`Q` 表示活跃队列数,`K` 表示希望每条队列同时保留的 
entry 数量。初始预算可按以下公式估算,再用混合负载压测调整:
+
+```text
+节点订阅物化预算 >= E × Q × K × 1.2
+```
+
+`K=1` 是最小容量估算;需要在等待 ACK 时继续物化,可从 `K=2` 开始。20% 余量是容量估算起点,并非整个 JVM 的内存保证。写入 
request、序列化响应缓存及转换中的临时对象还会占用其它内存;`E` 应使用物化后的内存估算,不能直接套用网络帧大小。
+
+例如,最大物化 entry 为 60 MB、4 条活跃队列时,`K=1` 的估算为 288 MB,`K=2` 为 576 MB。如果节点已有约 987 MB 
的物化预算,则每条队列约有 247 MB。这个容量本身可以容纳 30–60 MB 级 entry,无需仅因实时拒收就提高缓存比例;应先核实拒收原因、ACK 
速度和物化估算大小。权重对应的内存量还取决于实际 query 内存预算,不能将固定权重当成固定 MB 数。
+
+## 准入、错误及恢复
+
+活跃队列均分节点物化预算。队列达到自己的额度后停止接收新的实时引用,已在 pending 队列中的 entry 会保留;未接收的实时 entry 
仍可通过保留的 WAL 回放。已物化的数据可以继续投递,并在 ACK 后释放额度。
+
+新队列激活后,配额会重新计算。旧队列已分配的内存通过投递和 ACK 
逐步释放,因此重新分配额度可能出现短暂等待。队列停用时,在其保留内存释放之前不会把这份额度提前分给其它队列。
+
+单条 entry 超过该队列整个额度时,明确拒绝物化,并在队列已可投递的数据处理完后返回带 `SUBSCRIPTION_OVERSIZED_ENTRY` 的 
critical `ERROR` poll 响应。此时不会跳过 entry,也不会推进其 WAL/提交进度。对于历史积压,应增加物化预算、减少并发队列或调整 
topic 范围后重新消费;减小未来的写入 batch 不会缩小已经写入 WAL 的 entry。重放仍受 WAL 保留策略约束。
+
+拒收日志包含 `reasonCode`、`group`、`queueSize` 和 `queueRemaining`,文案说明实时准入拒绝与 WAL 
补投。常见原因如下:
+
+| `reasonCode` | 含义 |
+| --- | --- |
+| `SUBSCRIPTION_MEMORY_QUOTA` | 队列已有保留数据,本次物化或实时准入超过自身可用额度 |
+| `SUBSCRIPTION_MEMORY_LIMIT` | 物化时节点额度暂时不足,例如新队列激活后旧分配尚未释放 |
+| `SUBSCRIPTION_OVERSIZED_ENTRY` | 单条物化 entry 超过队列整个额度,等待 ACK 也无法使它装入 |
+| `SUBSCRIPTION_WRITER_BACKLOG` | 已物化的数据等待 writer 顺序条件满足 |
+| `SUBSCRIPTION_QUEUE_CAPACITY` | pending 队列条数达到容量上限 |
+| `CONSENSUS_REQUEST_MEMORY_LIMIT` | 实时 request 的 consensus 内存预留失败 |
+| `SUBSCRIPTION_QUEUE_INACTIVE_OR_CLOSED` / `SUBSCRIPTION_SEEK_IN_PROGRESS` | 
队列停用、关闭或正在 seek |
+
+## 监控及压测验收
+
+以下指标按 `name`(consumer group/topic)和 `region` 区分队列,需启用指标采集:
+
+| 指标 | 含义 |
+| --- | --- |
+| `subscription_consensus_memory_used` | 队列保留的物化内存字节数 |
+| `subscription_consensus_memory_quota` | 当前队列物化额度字节数 |
+| `subscription_consensus_admission_rejections` | 队列生命周期内实时 offer 被拒绝的累计次数 |
+| `subscription_consensus_memory_rejections` | 内存额度/节点预算导致 offer 或物化分配失败的累计次数 |
+| `subscription_consensus_oversized_rejections` | 超大 entry 导致 offer 
或物化分配失败的累计次数 |
+
+拒收累计值不是永久丢失计数。应结合消费进度、WAL lag、投递速率及 ACK 延迟判断积压能否追平。监控采集可对累计值计算增量;队列重建后累计值会重新开始。
+
+大字段和普通数值写入混跑时,应确认大帧队列达到自身额度后,普通队列仍能持续投递。停止写入后应等待已知写入目标全部消费完成,并核对源端查询量、写入成功量及消费量;单纯以一段时间无
 message 作为完成判据,可能在写入仍进行时提前退出。
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..4e582c79824 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
@@ -39,13 +39,13 @@ 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();
+  private final AtomicLong rejectedEntries = new AtomicLong();
+  private final AtomicLong lastRejectionLogTimeMs = new AtomicLong();
 
   public SubscriptionQueueRegistry(final String consensusGroupId) {
     this.consensusGroupId = consensusGroupId;
@@ -141,27 +141,33 @@ 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 long rejectedCount = rejectedEntries.incrementAndGet();
         final long now = System.currentTimeMillis();
-        final long lastLogTime = lastDropLogTimeMs.get();
-        if (now - lastLogTime >= QUEUE_FULL_LOG_INTERVAL_MS
-            && lastDropLogTimeMs.compareAndSet(lastLogTime, now)) {
+        final long lastLogTime = lastRejectionLogTimeMs.get();
+        if (now - lastLogTime >= REJECTION_LOG_INTERVAL_MS
+            && lastRejectionLogTimeMs.compareAndSet(lastLogTime, now)) {
           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,
+              rejectedEntries.getAndSet(0),
+              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 1c0362b627b..dfbd4e42b24 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
@@ -2645,4 +2645,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_EXCEEDING_QUEUE_QUOTA_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_THE_SUBSCRIPTION_PROPORTION_IN_CHUNK_TIMESERIESMETA_FREE_MEMORY_PROPORTION_WAL_PROGRESS_HAS_NOT_ADVANCED_C9120068
 =
+      "[%s] Subscription entry requires %d bytes, exceeding queue quota %d 
bytes (DataNode budget %d bytes). Reduce the write batch/field size or increase 
the Subscription proportion in chunk_timeseriesmeta_free_memory_proportion. 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 49096659ade..744c887bd7b 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_EXCEEDING_QUEUE_QUOTA_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_THE_SUBSCRIPTION_PROPORTION_IN_CHUNK_TIMESERIESMETA_FREE_MEMORY_PROPORTION_WAL_PROGRESS_HAS_NOT_ADVANCED_C9120068
 =
+      "[%s] 订阅 entry 需要 %d 字节,超过队列配额 %d 字节(DataNode 预算 %d 字节)。请减小写入 
batch/字段大小,或提高 chunk_timeseriesmeta_free_memory_proportion 中的 Subscription 
比例。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 b692ec94ed0..8efff2b6cf4 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;
@@ -95,7 +97,7 @@ import java.util.concurrent.atomic.AtomicLong;
 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 +153,24 @@ 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 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 +435,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 +488,7 @@ public class ConsensusPrefetchingQueue {
           retainedRequestBytes.addAndGet(request.getRetainedMemorySize());
         } else {
           requestMemoryManager.free(request);
+          reject(SubscriptionQueueRejectionReason.QUEUE_CAPACITY);
         }
       }
       if (offered) {
@@ -478,6 +517,11 @@ public class ConsensusPrefetchingQueue {
     private long getRetainedRequestBytes() {
       return retainedRequestBytes.get();
     }
+
+    @Override
+    public SubscriptionQueueRejectionReason getLastRejectionReason() {
+      return lastOfferRejectionReason.get();
+    }
   }
 
   private static final class PendingSeekRequest {
@@ -559,6 +603,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 +623,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 +649,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 +693,52 @@ 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 SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA;
+    }
+    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();
+    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 +858,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_EXCEEDING_QUEUE_QUOTA_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_THE_SUBSCRIPTION_PROPORTION_IN_CHUNK_TIMESERIESMETA_FREE_MEMORY_PROPORTION_WAL_PROGRESS_HAS_NOT_ADVANCED_C9120068,
+                    
SubscriptionQueueRejectionReason.SUBSCRIPTION_OVERSIZED_ENTRY.getCode(),
+                    blockedEntryBytes,
+                    subscriptionMemoryHandle.getMemoryQuotaInBytes(),
+                    subscriptionMemoryManager.getTotalMemorySizeInBytes()),
+                true),
+            
createNonCommittableContext(IoTDBDescriptor.getInstance().getConfig().getDataNodeId()));
+      }
       return event;
     } finally {
       releaseReadLock();
@@ -1428,7 +1529,7 @@ public class ConsensusPrefetchingQueue {
       recycleInFlightEvents();
 
       if (!isActive) {
-        blockRealtimeAdmission();
+        
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
         return computeIdleRoundResult();
       }
       if (prefetchingQueue.size() >= MAX_PREFETCHING_QUEUE_SIZE) {
@@ -1451,12 +1552,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();
@@ -1501,7 +1602,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();
@@ -1516,7 +1617,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) {
@@ -2321,11 +2422,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;
   }
@@ -2333,22 +2442,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 = 
SubscriptionQueueRejectionReason.SUBSCRIPTION_MEMORY_QUOTA;
+        return true;
+      }
+      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;
   }
 
@@ -2366,7 +2474,20 @@ 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 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() {
@@ -3032,7 +3153,7 @@ public class ConsensusPrefetchingQueue {
   // ======================== Cleanup ========================
 
   public void cleanUp() {
-    blockRealtimeAdmission();
+    
blockRealtimeAdmission(SubscriptionQueueRejectionReason.INACTIVE_OR_CLOSED);
     acquireWriteLock();
     try {
       prefetchingQueue.forEach(event -> cleanUpEvent(event, true));
@@ -3048,7 +3169,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;
@@ -3059,6 +3181,8 @@ public class ConsensusPrefetchingQueue {
       pendingSubscriptionWalResetSearchIndex = Long.MIN_VALUE;
       pendingSubscriptionWalResetGeneration = Long.MIN_VALUE;
       closeSubscriptionWALIterator();
+      subscriptionMemoryHandle.setActive(false);
+      subscriptionMemoryHandle.close();
 
     } finally {
       releaseWriteLock();
@@ -3317,7 +3441,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));
@@ -3327,6 +3451,7 @@ public class ConsensusPrefetchingQueue {
     discardRetainedEventMemory();
 
     // 2. Discard stale pending entries from in-memory queue
+    pendingEntries.clear();
     memoryBlockedEntryBytes = -1L;
     unblockRealtimeAdmission();
 
@@ -4038,12 +4163,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;
@@ -4394,6 +4521,30 @@ public class ConsensusPrefetchingQueue {
     return subscriptionMemoryManager.getTotalMemorySizeInBytes();
   }
 
+  public long getSubscriptionMemoryUsedInBytes() {
+    return subscriptionMemoryHandle.getUsedMemorySizeInBytes();
+  }
+
+  public long getSubscriptionMemoryQuotaInBytes() {
+    return subscriptionMemoryHandle.getMemoryQuotaInBytes();
+  }
+
+  public long getRealtimeAdmissionRejectionCount() {
+    return realtimeAdmissionRejectionCount.get();
+  }
+
+  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();
@@ -4471,6 +4622,15 @@ public class ConsensusPrefetchingQueue {
     result.put(
         "subscriptionMemoryLimitInBytes",
         String.valueOf(subscriptionMemoryManager.getTotalMemorySizeInBytes()));
+    result.put("subscriptionMemoryUsedInBytes", 
String.valueOf(getSubscriptionMemoryUsedInBytes()));
+    result.put(
+        "subscriptionMemoryQuotaInBytes", 
String.valueOf(getSubscriptionMemoryQuotaInBytes()));
+    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..36eb3299f5f 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
@@ -199,6 +199,36 @@ 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_ADMISSION_REJECTIONS.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getRealtimeAdmissionRejectionCount,
+        key.getTags());
+    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 +339,24 @@ 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_ADMISSION_REJECTIONS.toString(),
+        key.getTags());
+    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) {
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..bf51ab9a32c 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,28 @@ 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 and receive an equal share of 
the configured budget.
+ * A queue cannot consume another queue's share, while an entry larger than a 
share is rejected with
+ * an explicit reason, even when the node budget is otherwise empty. Quotas 
change as queues become
+ * active or release their retained data; existing allocations are drained 
rather than revoked.
+ */
 public class SubscriptionMemoryManager {
 
   private static final String MEMORY_BLOCK_NAME = "Subscription";
 
   private final IMemoryBlock memoryBlock;
+  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 +63,300 @@ 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(),
+        Math.min(
+            handle.getMemoryQuotaInBytesUnderLock() - handle.usedMemoryInBytes,
+            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(),
+          getFreeMemorySizeInBytes());
+    }
+
+    final long total = getTotalMemorySizeInBytes();
+    final long used = getUsedMemorySizeInBytes();
+    final long quota = handle.getMemoryQuotaInBytesUnderLock();
+    final long ownerUsed = handle.usedMemoryInBytes;
+    final boolean fitsInNode = total > 0L && used <= total && sizeInBytes <= 
total - used;
+    final boolean fitsInShare = ownerUsed <= quota && sizeInBytes <= quota - 
ownerUsed;
+
+    if (fitsInNode && fitsInShare) {
+      return AllocationResult.accepted(quota, quota - 
handle.usedMemoryInBytes);
+    }
+
+    final AllocationRejectionReason reason;
+    if (sizeInBytes > quota) {
+      reason = AllocationRejectionReason.OVERSIZED_ENTRY;
+    } else if (!fitsInShare) {
+      reason = AllocationRejectionReason.MEMORY_QUOTA;
+    } else {
+      reason = AllocationRejectionReason.MEMORY_LIMIT;
+    }
+    return AllocationResult.rejected(reason, quota, Math.min(quota - 
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 getFreeMemorySizeInBytes() {
+      synchronized (SubscriptionMemoryManager.this) {
+        // Realtime request admission uses only this queue's quota. Node-wide 
allocation checks
+        // happen during materialization, including when pre-existing 
allocations exceed new shares.
+        return Math.max(0L, getMemoryQuotaInBytesUnderLock() - 
usedMemoryInBytes);
+      }
+    }
+
+    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..190b795f7e3 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,187 @@ 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 =
+          new SubscriptionMemoryManager(largeBytes * 2L);
+      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());
+
+      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 +261,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 +306,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());
+      assertEquals("true", 
queueB.coreReportMessage().get("realtimeAdmissionBlocked"));
 
       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 +382,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 +436,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 +472,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 0f321341401..68df431cf63 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;
@@ -635,7 +638,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);
@@ -645,7 +649,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();
@@ -657,11 +661,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(
@@ -714,6 +724,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
@@ -722,7 +803,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);
@@ -734,10 +816,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 {
@@ -749,6 +841,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();
@@ -988,6 +1083,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -1760,6 +1856,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -1828,6 +1925,7 @@ public class ConsensusPrefetchingQueueTest {
               1L,
               1L,
               true);
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L * 
1024L));
 
       final IndexedConsensusRequest dataRequest =
           new IndexedConsensusRequest(
@@ -2636,6 +2734,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/resource/SubscriptionMemoryManagerTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/resource/SubscriptionMemoryManagerTest.java
index 1635a6e0ecf..087c5811776 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,79 @@ public class SubscriptionMemoryManagerTest {
     assertFalse(memoryManager.tryAllocate(1L));
     assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
   }
+
+  @Test
+  public void testQueueCannotBorrowAnotherQueuesShare() {
+    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(6L));
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+
+    assertFalse(queueA.tryAllocate(6L).isAccepted());
+    assertTrue(queueA.tryAllocate(4L).isAccepted());
+    assertEquals(
+        SubscriptionMemoryManager.AllocationRejectionReason.MEMORY_QUOTA,
+        queueA.inspectRejection(2L));
+    assertTrue(queueB.tryAllocate(5L).isAccepted());
+    assertTrue(queueA.tryAllocate(1L).isAccepted());
+    assertEquals(10L, memoryManager.getUsedMemorySizeInBytes());
+
+    queueA.close();
+    assertEquals(10L, queueB.getMemoryQuotaInBytes());
+    assertTrue(queueB.tryAllocate(5L).isAccepted());
+    assertEquals(10L, memoryManager.getUsedMemorySizeInBytes());
+
+    queueB.close();
+    assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+  }
+
+  @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..30d55945cb7 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,12 @@ 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. Active queues divide this budget 
equally. Size it
+# 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 a queue's quota 
reports
+# SUBSCRIPTION_OVERSIZED_ENTRY and does not advance WAL progress. Existing 
allocations drain via ACK
+# when new queues reduce their quotas.
 # effectiveMode: restart
 chunk_timeseriesmeta_free_memory_proportion=1:100:200:50:200:200:200:50:250
 
@@ -2153,6 +2159,27 @@ 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.
+# This does not control consensus subscription tablet materialization. Start 
with 0.2; for large
+# responses, evaluate 0.3 or 0.4 only if cache eviction/reserialization is 
high and pipe memory has
+# headroom. 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..31b6ff2820a 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,11 @@ 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_ADMISSION_REJECTIONS("subscription_consensus_admission_rejections"),
+  
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"),

Reply via email to