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

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


The following commit(s) were added to refs/heads/master by this push:
     new e3bbab5e7e8 [Subscription] Support per-topic uncommitted event recycle 
timeouts (#18876)
e3bbab5e7e8 is described below

commit e3bbab5e7e851b7411a5655b67996c8fcae631cf
Author: Caideyipi <[email protected]>
AuthorDate: Thu Oct 15 14:12:52 2026 +0800

    [Subscription] Support per-topic uncommitted event recycle timeouts (#18876)
    
    * [Subscription] Support per-topic uncommitted event recycle timeouts
    
    * [Subscription] Allow disabling uncommitted event recycling
---
 .../iotdb/rpc/subscription/config/TopicConfig.java |  11 ++
 .../rpc/subscription/config/TopicConstant.java     |   4 +
 .../rpc/subscription/config/TopicConfigTest.java   |  22 +++
 .../iotdb/confignode/i18n/ConfigNodeMessages.java  |   3 +
 .../iotdb/confignode/i18n/ConfigNodeMessages.java  |   3 +
 .../persistence/subscription/SubscriptionInfo.java |  28 ++++
 .../SubscriptionInfoTopicValidationTest.java       | 106 +++++++++++++
 .../agent/SubscriptionBrokerAgent.java             |  10 ++
 .../subscription/agent/SubscriptionTopicAgent.java |  47 ++++++
 .../broker/ConsensusSubscriptionBroker.java        |   8 +
 .../broker/SubscriptionPrefetchingQueue.java       |  13 +-
 .../consensus/ConsensusPrefetchingQueue.java       |  23 ++-
 .../db/subscription/event/SubscriptionEvent.java   |  14 +-
 .../agent/SubscriptionTopicAgentTest.java          | 116 ++++++++++++++
 ...riptionPrefetchingQueueRecycleIntervalTest.java | 160 +++++++++++++++++++
 .../consensus/ConsensusPrefetchingQueueTest.java   | 170 ++++++++++++++++++++-
 .../conf/iotdb-system.properties.template          |  13 ++
 17 files changed, 736 insertions(+), 15 deletions(-)

diff --git 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
index 51b87f910e2..bc4a5451cf3 100644
--- 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
+++ 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
@@ -190,6 +190,17 @@ public class TopicConfig extends PipeParameters {
     return ORDER_MODE_VALUE_SET.contains(normalizeOrderMode(orderMode));
   }
 
+  /**
+   * Returns the topic's recycle timeout in milliseconds, or null to use the 
server config. A zero
+   * or negative timeout disables automatic recycling of uncommitted events.
+   */
+  public Long getRecycleUncommittedEventIntervalMs() {
+    return 
containsKeyIgnoreCase(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY)
+        ? Long.parseLong(
+            
getStringIgnoreCase(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
"").trim())
+        : null;
+  }
+
   public static String normalizeOrderMode(final String orderMode) {
     return orderMode == null
         ? TopicConstant.ORDER_MODE_DEFAULT_VALUE
diff --git 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java
 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java
index 960e3bab524..8a8af71d738 100644
--- 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java
+++ 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java
@@ -88,6 +88,10 @@ public class TopicConstant {
   public static final String STRICT_KEY = "strict";
   public static final String STRICT_DEFAULT_VALUE = "true";
 
+  // Overrides subscription_recycle_uncommitted_event_interval_ms for this 
topic.
+  public static final String RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY =
+      "recycle-uncommitted-event-interval-ms";
+
   public static final String OWNER_ID_KEY = "owner-id";
   public static final String OWNER_EPOCH_KEY = "owner-epoch";
   // Highest owner epoch ever granted for this topic. Retained across 
clearOwner so that owner
diff --git 
a/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
 
b/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
index 3b32d407eba..0235feb4298 100644
--- 
a/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
+++ 
b/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
@@ -49,6 +49,28 @@ public class TopicConfigTest {
     Assert.assertTrue(topicConfigWithMode(" INCREMENTAL 
").isIncrementalMode());
   }
 
+  @Test
+  public void testRecycleUncommittedEventInterval() {
+    Assert.assertNull(new 
TopicConfig().getRecycleUncommittedEventIntervalMs());
+
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, " 
120000 ");
+    Assert.assertEquals(
+        Long.valueOf(120000L), new 
TopicConfig(attributes).getRecycleUncommittedEventIntervalMs());
+
+    attributes.clear();
+    attributes.put("RECYCLE-UNCOMMITTED-EVENT-INTERVAL-MS", "300000");
+    Assert.assertEquals(
+        Long.valueOf(300000L), new 
TopicConfig(attributes).getRecycleUncommittedEventIntervalMs());
+
+    for (final long intervalMs : new long[] {0L, -1L, Long.MIN_VALUE}) {
+      attributes.put("RECYCLE-UNCOMMITTED-EVENT-INTERVAL-MS", " " + intervalMs 
+ " ");
+      Assert.assertEquals(
+          Long.valueOf(intervalMs),
+          new TopicConfig(attributes).getRecycleUncommittedEventIntervalMs());
+    }
+  }
+
   @SuppressWarnings("deprecation")
   @Test
   public void testLegacyModeValues() {
diff --git 
a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
 
b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
index fb73cc93376..60d672cc490 100644
--- 
a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
+++ 
b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
@@ -20,6 +20,9 @@
 package org.apache.iotdb.confignode.i18n;
 
 public final class ConfigNodeMessages {
+  public static final String
+      
EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ILLEGAL_ARG_ARG_EXPECTED_A_LONG_VALUE_IN_MILLISECONDS_F79736B2
 =
+          "Failed to create or alter topic, illegal %s=%s, expected a long 
value in milliseconds";
 
   public static final String 
LOG_CONFIGREGION_UPDATE_CONFIGNODE_NODEID_ARG_CONSENSUSENDPOINT_ARG_IS_A_CONFIGREGION_FOLLOWER_NEW_LEADER_NODEID_ARG_STOPPING_LEADER_SERVICES_A1962CD2
 =
       "[ConfigRegion Update] ConfigNode [nodeId: {}, consensusEndpoint: {}] is 
a ConfigRegion follower; new leader nodeId: {}. Stopping leader services.";
diff --git 
a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
 
b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
index 948f4a64765..1b3926590cb 100644
--- 
a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
+++ 
b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
@@ -20,6 +20,9 @@
 package org.apache.iotdb.confignode.i18n;
 
 public final class ConfigNodeMessages {
+  public static final String
+      
EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ILLEGAL_ARG_ARG_EXPECTED_A_LONG_VALUE_IN_MILLISECONDS_F79736B2
 =
+          "创建或修改 topic 失败,%s=%s 无效,需指定以毫秒为单位的 long 值";
 
   public static final String 
LOG_CONFIGREGION_UPDATE_CONFIGNODE_NODEID_ARG_CONSENSUSENDPOINT_ARG_IS_A_CONFIGREGION_FOLLOWER_NEW_LEADER_NODEID_ARG_STOPPING_LEADER_SERVICES_A1962CD2
 =
       "[ConfigRegion Update] ConfigNode [nodeId: {}, consensusEndpoint: {}] 为 
ConfigRegion follower;新 leader 的 nodeId: {}。开始停止 leader 服务。";
diff --git 
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java
 
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java
index 9fab5d1f536..58fe0a10690 100644
--- 
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java
+++ 
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java
@@ -109,6 +109,7 @@ public class SubscriptionInfo implements SnapshotProcessor {
           TopicConstant.RETENTION_BYTES_KEY,
           TopicConstant.RETENTION_MS_KEY,
           TopicConstant.RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY,
+          TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
           TopicConstant.START_TIME_KEY,
           TopicConstant.END_TIME_KEY,
           TopicConstant.MODE_KEY,
@@ -131,6 +132,7 @@ public class SubscriptionInfo implements SnapshotProcessor {
           TopicConstant.RETENTION_BYTES_KEY,
           TopicConstant.RETENTION_MS_KEY,
           TopicConstant.RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY,
+          TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
           TopicConstant.MODE_KEY,
           TopicConstant.ORDER_MODE_KEY,
           TopicConstant.FORMAT_KEY,
@@ -399,6 +401,7 @@ public class SubscriptionInfo implements SnapshotProcessor {
 
     validateColumnFilter(topicConfig);
     validateIncrementalTopicRetentionConfig(topicConfig);
+    validateRecycleUncommittedEventInterval(topicConfig);
 
     final Long ownerLeaseDurationMs =
         topicConfig.getLong(TopicConstant.OWNER_LEASE_DURATION_MS_KEY);
@@ -586,6 +589,31 @@ public class SubscriptionInfo implements SnapshotProcessor 
{
     return topicConfig.isIncrementalMode();
   }
 
+  private void validateRecycleUncommittedEventInterval(final TopicConfig 
topicConfig)
+      throws SubscriptionException {
+    try {
+      topicConfig.getRecycleUncommittedEventIntervalMs();
+      return;
+    } catch (final NumberFormatException ignored) {
+      // Non-positive values disable automatic recycling; only malformed 
values are rejected.
+    }
+    final String exceptionMessage =
+        String.format(
+            ConfigNodeMessages
+                
.EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ILLEGAL_ARG_ARG_EXPECTED_A_LONG_VALUE_IN_MILLISECONDS_F79736B2,
+            TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
+            topicConfig.getAttribute().entrySet().stream()
+                .filter(
+                    entry ->
+                        
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY.equalsIgnoreCase(
+                            entry.getKey()))
+                .map(entry -> String.valueOf(entry.getValue()))
+                .findFirst()
+                .orElse(""));
+    LOGGER.warn(exceptionMessage);
+    throw new SubscriptionException(exceptionMessage);
+  }
+
   private void validateIncrementalTopicRetentionConfig(final TopicConfig 
topicConfig)
       throws SubscriptionException {
     if (!hasTopicAttributeIgnoreCase(topicConfig, 
TopicConstant.RETENTION_BYTES_KEY)
diff --git 
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java
 
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java
index da9c356190c..f9e7e7a2f45 100644
--- 
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java
+++ 
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java
@@ -305,6 +305,112 @@ public class SubscriptionInfoTopicValidationTest {
     assertCreateRejected(subscriptionInfo, attributes, "expected a long 
value");
   }
 
+  @Test
+  public void testAcceptRecycleUncommittedEventInterval() throws Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+
+    final Map<String, String> initialAttributes = 
newInitialTableTopicAttributes();
+    initialAttributes.put(
+        TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, " 
9223372036854775807 ");
+    Assert.assertTrue(
+        subscriptionInfo.validateBeforeCreatingTopic(
+            new 
TCreateTopicReq("initial_topic").setTopicAttributes(initialAttributes)));
+
+    final Map<String, String> incrementalAttributes = 
newIncrementalTableTopicAttributes();
+    incrementalAttributes.put("Recycle-Uncommitted-Event-Interval-Ms", 
"300000");
+    Assert.assertTrue(
+        subscriptionInfo.validateBeforeCreatingTopic(
+            new 
TCreateTopicReq("incremental_topic").setTopicAttributes(incrementalAttributes)));
+
+    for (final String format :
+        new String[] {
+          TopicConstant.FORMAT_TS_FILE_VALUE, 
TopicConstant.FORMAT_RECORD_HANDLER_VALUE
+        }) {
+      final Map<String, String> snapshotAttributes = new HashMap<>();
+      snapshotAttributes.put(TopicConstant.MODE_KEY, 
TopicConstant.MODE_SNAPSHOT_VALUE);
+      snapshotAttributes.put(TopicConstant.FORMAT_KEY, format);
+      
snapshotAttributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
"1200000");
+      Assert.assertTrue(
+          subscriptionInfo.validateBeforeCreatingTopic(
+              new 
TCreateTopicReq("snapshot_topic").setTopicAttributes(snapshotAttributes)));
+    }
+  }
+
+  @Test
+  public void testAcceptDisablingRecycleUncommittedEvents() throws Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    for (final String mode :
+        new String[] {
+          TopicConstant.MODE_INITIAL_VALUE,
+          TopicConstant.MODE_INCREMENTAL_VALUE,
+          TopicConstant.MODE_SNAPSHOT_VALUE
+        }) {
+      for (final String value : new String[] {"0", "-1", 
"-9223372036854775808"}) {
+        final Map<String, String> attributes = 
newInitialTableTopicAttributes();
+        attributes.put(TopicConstant.MODE_KEY, mode);
+        
attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, value);
+        Assert.assertTrue(
+            subscriptionInfo.validateBeforeCreatingTopic(
+                new 
TCreateTopicReq("disabled_recycle_topic").setTopicAttributes(attributes)));
+      }
+    }
+  }
+
+  @Test
+  public void testRejectInvalidRecycleUncommittedEventInterval() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    for (final String value :
+        new String[] {
+          "not-a-number", "", " ", "1.5", "9223372036854775808", 
"-9223372036854775809", null
+        }) {
+      final Map<String, String> attributes = newInitialTableTopicAttributes();
+      attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
value);
+      assertCreateRejected(subscriptionInfo, attributes, "expected a long 
value in milliseconds");
+    }
+  }
+
+  @Test
+  public void testAcceptAlteringRecycleUncommittedEventInterval() throws 
Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    for (final String mode :
+        new String[] {TopicConstant.MODE_INITIAL_VALUE, 
TopicConstant.MODE_INCREMENTAL_VALUE}) {
+      final Map<String, String> attributes = newInitialTableTopicAttributes();
+      attributes.put(TopicConstant.MODE_KEY, mode);
+      attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
"1200000");
+      subscriptionInfo.createTopic(new CreateTopicPlan(new TopicMeta(mode, 1L, 
attributes)));
+
+      for (final String value : new String[] {"300000", "0", "-1", 
"-9223372036854775808"}) {
+        final Map<String, String> updatedAttributes = new 
HashMap<>(attributes);
+        
updatedAttributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
value);
+        subscriptionInfo.validateBeforeAlteringTopic(new TopicMeta(mode, 1L, 
updatedAttributes));
+      }
+    }
+  }
+
+  @Test
+  public void testRejectAlteringInvalidRecycleUncommittedEventInterval() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newInitialTableTopicAttributes();
+    subscriptionInfo.createTopic(new CreateTopicPlan(new 
TopicMeta("table_topic", 1L, attributes)));
+    attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
"not-a-number");
+    try {
+      subscriptionInfo.validateBeforeAlteringTopic(new 
TopicMeta("table_topic", 1L, attributes));
+      Assert.fail("Expected an invalid recycle interval to be rejected");
+    } catch (final SubscriptionException e) {
+      Assert.assertTrue(e.getMessage().contains("expected a long value in 
milliseconds"));
+    }
+  }
+
+  @Test
+  public void testRejectDuplicateRecycleUncommittedEventIntervals() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newInitialTableTopicAttributes();
+    attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
"1200000");
+    attributes.put("RECYCLE-UNCOMMITTED-EVENT-INTERVAL-MS", "300000");
+    assertCreateRejected(
+        subscriptionInfo, attributes, "duplicate 
recycle-uncommitted-event-interval-ms attributes");
+  }
+
   @Test
   public void testRejectAlteringRetentionConfig() throws Exception {
     final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
index 1c429cd97dc..aafcf903dc4 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
@@ -719,6 +719,16 @@ public class SubscriptionBrokerAgent {
     }
   }
 
+  public void wakeUpConsensusPrefetchingQueues(final String topicName, final 
boolean isTableModel) {
+    for (final List<ISubscriptionBroker> brokers : 
consumerGroupIdToBrokers.values()) {
+      final ConsensusSubscriptionBroker broker =
+          getBroker(brokers, ConsensusSubscriptionBroker.class);
+      if (Objects.nonNull(broker)) {
+        broker.wakeUpPrefetchingQueues(topicName, isTableModel);
+      }
+    }
+  }
+
   public void refreshColumnFilter(final String topicName, final TopicConfig 
topicConfig) {
     final ColumnFilterMatcher matcher;
     try {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java
index f4cb98d823e..f68e60f353f 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java
@@ -26,6 +26,7 @@ import 
org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
 import org.apache.iotdb.commons.pipe.datastructure.pattern.IoTDBTreePattern;
 import org.apache.iotdb.commons.pipe.datastructure.pattern.PrefixTreePattern;
 import org.apache.iotdb.commons.pipe.datastructure.pattern.TreePattern;
+import org.apache.iotdb.commons.subscription.config.SubscriptionConfig;
 import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
 import org.apache.iotdb.commons.subscription.meta.topic.TopicMetaKeeper;
 import org.apache.iotdb.db.auth.AuthorityChecker;
@@ -40,14 +41,17 @@ import 
org.apache.iotdb.rpc.subscription.config.ConsumerConfig;
 import org.apache.iotdb.rpc.subscription.config.TopicConfig;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 
+import org.apache.tsfile.utils.Pair;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 
 public class SubscriptionTopicAgent {
@@ -56,6 +60,12 @@ public class SubscriptionTopicAgent {
 
   private final TopicMetaKeeper topicMetaKeeper;
 
+  // Queue operations read this derived runtime config without acquiring the 
topic-meta lock.
+  // Topic-meta updates can wake queues while holding that lock, so taking it 
from a queue operation
+  // would reverse the lock order. The data model is part of the key to 
isolate same-name topics.
+  private final Map<Pair<String, Boolean>, Long> topicRecycleIntervalsMs =
+      new ConcurrentHashMap<>();
+
   public SubscriptionTopicAgent() {
     this.topicMetaKeeper = new TopicMetaKeeper();
   }
@@ -109,8 +119,18 @@ public class SubscriptionTopicAgent {
     final boolean isTableModel = metaFromCoordinator.visibleUnderTableModel();
     final TopicMeta oldMeta = topicMetaKeeper.getTopicMeta(topicName, 
isTableModel);
     TopicMeta.validateOwnerProgression(oldMeta, metaFromCoordinator);
+    final Long recycleIntervalMs =
+        metaFromCoordinator.getConfig().getRecycleUncommittedEventIntervalMs();
     topicMetaKeeper.removeTopicMeta(topicName, isTableModel);
     topicMetaKeeper.addTopicMeta(topicName, metaFromCoordinator);
+    final Pair<String, Boolean> topicKey = new Pair<>(topicName, isTableModel);
+    final Long oldRecycleIntervalMs =
+        Objects.isNull(recycleIntervalMs)
+            ? topicRecycleIntervalsMs.remove(topicKey)
+            : topicRecycleIntervalsMs.put(topicKey, recycleIntervalMs);
+    if (!Objects.equals(oldRecycleIntervalMs, recycleIntervalMs)) {
+      SubscriptionAgent.broker().wakeUpConsensusPrefetchingQueues(topicName, 
isTableModel);
+    }
     if (shouldRefreshColumnFilter(oldMeta, metaFromCoordinator)) {
       SubscriptionAgent.broker().refreshColumnFilter(topicName, 
metaFromCoordinator.getConfig());
     } else if (!metaFromCoordinator.getConfig().isTableTopic()
@@ -173,9 +193,14 @@ public class SubscriptionTopicAgent {
       final List<TopicMeta> topicMetasFromCoordinator) {
     acquireWriteLock();
     try {
+      final Set<Pair<String, Boolean>> currentTopics = new HashSet<>();
       for (final TopicMeta topicMetaFromCoordinator : 
topicMetasFromCoordinator) {
         try {
           handleSingleTopicMetaChangesInternal(topicMetaFromCoordinator);
+          currentTopics.add(
+              new Pair<>(
+                  topicMetaFromCoordinator.getTopicName(),
+                  topicMetaFromCoordinator.visibleUnderTableModel()));
         } catch (final Exception e) {
           final String topicName = topicMetaFromCoordinator.getTopicName();
           LOGGER.warn(
@@ -191,6 +216,15 @@ public class SubscriptionTopicAgent {
               topicName, exceptionMessage, System.currentTimeMillis());
         }
       }
+      // Remove absent topics only after applying the full snapshot. Clearing 
first would let queue
+      // operations temporarily use the global timeout and could recycle an 
event prematurely.
+      for (final Pair<String, Boolean> topicKey : 
topicRecycleIntervalsMs.keySet()) {
+        if (!currentTopics.contains(topicKey)) {
+          topicRecycleIntervalsMs.remove(topicKey);
+          SubscriptionAgent.broker()
+              .wakeUpConsensusPrefetchingQueues(topicKey.left, topicKey.right);
+        }
+      }
       return null;
     } finally {
       releaseWriteLock();
@@ -238,6 +272,19 @@ public class SubscriptionTopicAgent {
     if (Objects.nonNull(topicMeta) && topicMeta.visibleUnderTableModel()) {
       SubscriptionAgent.broker().dropColumnFilter(topicName);
     }
+    if (Objects.nonNull(topicMeta) || Objects.nonNull(isTableModel)) {
+      topicRecycleIntervalsMs.remove(
+          new Pair<>(
+              topicName,
+              Objects.nonNull(isTableModel) ? isTableModel : 
topicMeta.visibleUnderTableModel()));
+    }
+  }
+
+  public long getTopicRecycleUncommittedEventIntervalMs(
+      final String topicName, final boolean isTableModel) {
+    return topicRecycleIntervalsMs.getOrDefault(
+        new Pair<>(topicName, isTableModel),
+        (long) 
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs());
   }
 
   public boolean isTopicExisted(final String topicName) {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
index cd7abf043cf..60b268504a2 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
@@ -751,6 +751,14 @@ public class ConsensusSubscriptionBroker implements 
ISubscriptionBroker {
     closeAndRemoveConsensusPrefetchingQueues(topicName, true, 
retainProgressAfterUnsubscribe);
   }
 
+  public void wakeUpPrefetchingQueues(final String topicName, final boolean 
isTableModel) {
+    final List<ConsensusPrefetchingQueue> queues =
+        topicNameToConsensusPrefetchingQueues.get(topicName);
+    if (Objects.nonNull(queues)) {
+      queues.forEach(queue -> queue.wakeUpForTopicConfigChange(isTableModel));
+    }
+  }
+
   @Override
   public void unbind(final String topicName) {
     unbindConsensusPrefetchingQueue(topicName, false);
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
index 716e7d90f15..2756f4fa4a9 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
@@ -70,6 +70,7 @@ public abstract class SubscriptionPrefetchingQueue {
 
   private final String brokerId; // consumer group id
   private final String topicName;
+  private final boolean tableModel;
 
   /** Contains events coming from the upstream pipeline, corresponding to the 
pipe sink stage. */
   private final SubscriptionBlockingPendingQueue inputPendingQueue;
@@ -129,6 +130,7 @@ public abstract class SubscriptionPrefetchingQueue {
       final long maxBatchSizeInBytes) {
     this.brokerId = brokerId;
     this.topicName = topicName;
+    this.tableModel = SubscriptionAgent.consumer().isTableModel(brokerId);
     this.inputPendingQueue = inputPendingQueue;
     this.commitIdGenerator = commitIdGenerator;
     this.initialCommitId = commitIdGenerator.get();
@@ -157,6 +159,11 @@ public abstract class SubscriptionPrefetchingQueue {
     return brokerId;
   }
 
+  private long getRecycleUncommittedEventIntervalMs() {
+    return SubscriptionAgent.topic()
+        .getTopicRecycleUncommittedEventIntervalMs(topicName, tableModel);
+  }
+
   protected void cleanUpInternal() {
     // clean up events in batches
     batches.cleanUp();
@@ -329,7 +336,7 @@ public abstract class SubscriptionPrefetchingQueue {
         continue;
       }
 
-      if (!event.pollable()) {
+      if (!event.pollable(getRecycleUncommittedEventIntervalMs())) {
         LOGGER.warn(
             DataNodePipeMessages
                 
.PIPE_LOG_SUBSCRIPTION_SUBSCRIPTIONPREFETCHINGQUEUE_POLL_NON_POLLABLE_644D5D6B,
@@ -373,7 +380,7 @@ public abstract class SubscriptionPrefetchingQueue {
         continue;
       }
 
-      if (!event.pollable()) {
+      if (!event.pollable(getRecycleUncommittedEventIntervalMs())) {
         LOGGER.warn(
             DataNodePipeMessages
                 
.PIPE_LOG_SUBSCRIPTION_SUBSCRIPTIONPREFETCHINGQUEUE_POLL_NON_POLLABLE_644D5D6B,
@@ -1232,7 +1239,7 @@ public abstract class SubscriptionPrefetchingQueue {
           prefetchEvent(ev);
           // no need to log warn for eagerly pollable event
           return null; // remove this entry
-        } else if (ev.pollable()) {
+        } else if (ev.pollable(getRecycleUncommittedEventIntervalMs())) {
           ev.nack(); // now pollable
           if (ev.isPoisoned()) {
             LOGGER.error(
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 1dd733b4cae..4e71f2e2fe9 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
@@ -752,6 +752,17 @@ public class ConsensusPrefetchingQueue {
     }
   }
 
+  public void wakeUpForTopicConfigChange(final boolean isTableModel) {
+    if (tableModel == isTableModel) {
+      requestPrefetch();
+    }
+  }
+
+  private long getRecycleUncommittedEventIntervalMs() {
+    return SubscriptionAgent.topic()
+        .getTopicRecycleUncommittedEventIntervalMs(topicName, tableModel);
+  }
+
   private void requestPrefetchForRealtimeEntry() {
     if (!hasReachedPrefetchLimit()) {
       requestPrefetch();
@@ -1506,7 +1517,7 @@ public class ConsensusPrefetchingQueue {
           continue;
         }
 
-        if (!event.pollable()) {
+        if (!event.pollable(getRecycleUncommittedEventIntervalMs())) {
           LOGGER.warn(
               DataNodePipeMessages
                   
.PIPE_LOG_CONSENSUSPREFETCHINGQUEUE_POLL_NON_POLLABLE_EVENT_BROKEN_E9551325,
@@ -1939,10 +1950,10 @@ public class ConsensusPrefetchingQueue {
     }
 
     if (!inFlightEvents.isEmpty()) {
-      delayMs =
-          Math.min(
-              delayMs,
-              
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs());
+      final long recycleIntervalMs = getRecycleUncommittedEventIntervalMs();
+      if (recycleIntervalMs > 0L) {
+        delayMs = Math.min(delayMs, recycleIntervalMs);
+      }
     }
 
     return delayMs == Long.MAX_VALUE
@@ -3430,7 +3441,7 @@ public class ConsensusPrefetchingQueue {
               cleanUpEvent(ev, false);
               return null;
             }
-            if (ev.pollable()) {
+            if (ev.pollable(getRecycleUncommittedEventIntervalMs())) {
               ev.nack();
               if (ev.isPoisoned()) {
                 LOGGER.error(
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java
index 2e58a68411d..031e68780a2 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java
@@ -235,13 +235,18 @@ public class SubscriptionEvent implements 
Comparable<SubscriptionEvent> {
    *     are not pollable.
    */
   public boolean pollable() {
+    return pollable(
+        
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs());
+  }
+
+  public boolean pollable(final long recycleUncommittedEventIntervalMs) {
     if (isCommitted()) {
       return false;
     }
     if (lastPolledTimestamp.get() == INVALID_TIMESTAMP) {
       return true;
     }
-    return canRecycle();
+    return canRecycle(recycleUncommittedEventIntervalMs);
   }
 
   /**
@@ -255,11 +260,12 @@ public class SubscriptionEvent implements 
Comparable<SubscriptionEvent> {
     return lastPolledTimestamp.get() == INVALID_TIMESTAMP;
   }
 
-  private boolean canRecycle() {
+  private boolean canRecycle(final long recycleUncommittedEventIntervalMs) {
     // Recycle events that may not be able to be committed, i.e., those that 
have been polled but
     // not committed within a certain period of time.
-    return System.currentTimeMillis() - lastPolledTimestamp.get()
-        > 
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs();
+    return recycleUncommittedEventIntervalMs > 0L
+        && System.currentTimeMillis() - lastPolledTimestamp.get()
+            > recycleUncommittedEventIntervalMs;
   }
 
   public void nack() {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
index c31535b5812..1b09a53f559 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
@@ -19,6 +19,8 @@
 
 package org.apache.iotdb.db.subscription.agent;
 
+import org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
+import org.apache.iotdb.commons.subscription.config.SubscriptionConfig;
 import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 
@@ -26,10 +28,124 @@ import org.junit.Assert;
 import org.junit.Test;
 
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.spy;
+
 public class SubscriptionTopicAgentTest {
 
+  @Test
+  public void testRecycleIntervalUpdateAndFallback() {
+    final SubscriptionTopicAgent agent = new SubscriptionTopicAgent();
+    final long defaultInterval =
+        
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs();
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(false,
 "1200000")));
+    Assert.assertEquals(
+        1200000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+
+    for (final long intervalMs : new long[] {0L, -1L, Long.MIN_VALUE}) {
+      Assert.assertNull(
+          agent.handleSingleTopicMetaChanges(
+              createRecycleTopicMeta(false, Long.toString(intervalMs))));
+      Assert.assertEquals(
+          intervalMs, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+    }
+
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(false,
 "300000")));
+    Assert.assertEquals(
+        300000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(false,
 null)));
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+  }
+
+  @Test
+  public void testRecycleIntervalIsolationAndDrop() {
+    final SubscriptionTopicAgent agent = new SubscriptionTopicAgent();
+    final long defaultInterval =
+        
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs();
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(false,
 "1200000")));
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(true,
 "300000")));
+
+    Assert.assertEquals(
+        1200000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+    Assert.assertEquals(
+        300000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", true));
+
+    Assert.assertNull(agent.handleDropTopic("recycle_topic"));
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+    Assert.assertEquals(
+        300000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", true));
+    Assert.assertNull(agent.handleDropTopic("recycle_topic", true));
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", true));
+  }
+
+  @Test
+  public void testRecycleIntervalSnapshotPreservesCurrentOverride() {
+    final SubscriptionTopicAgent agent = new SubscriptionTopicAgent();
+    final TopicMeta topicMeta = spy(createRecycleTopicMeta(false, "0"));
+    Assert.assertNull(agent.handleSingleTopicMetaChanges(topicMeta));
+    doAnswer(
+            invocation -> {
+              Assert.assertEquals(
+                  0L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+              return invocation.callRealMethod();
+            })
+        .when(topicMeta)
+        .getConfig();
+
+    Assert.assertNull(agent.handleTopicMetaChanges(List.of(topicMeta)));
+  }
+
+  @Test
+  public void testRecycleIntervalSnapshotRemovesAbsentOverride() {
+    final SubscriptionTopicAgent agent = new SubscriptionTopicAgent();
+    final long defaultInterval =
+        
SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs();
+    Assert.assertNull(
+        agent.handleTopicMetaChanges(
+            List.of(
+                createRecycleTopicMeta(false, "1200000"), 
createRecycleTopicMeta(true, "300000"))));
+    
Assert.assertNull(agent.handleTopicMetaChanges(List.of(createRecycleTopicMeta(true,
 null))));
+
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+    Assert.assertEquals(
+        defaultInterval, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", true));
+  }
+
+  @Test
+  public void testInvalidSnapshotPreservesExistingRecycleInterval() {
+    final SubscriptionTopicAgent agent = new SubscriptionTopicAgent();
+    
Assert.assertNull(agent.handleSingleTopicMetaChanges(createRecycleTopicMeta(false,
 "1200000")));
+    Assert.assertNotNull(
+        agent.handleTopicMetaChanges(List.of(createRecycleTopicMeta(false, 
"invalid"))));
+    Assert.assertEquals(
+        1200000L, 
agent.getTopicRecycleUncommittedEventIntervalMs("recycle_topic", false));
+  }
+
+  private static TopicMeta createRecycleTopicMeta(
+      final boolean tableModel, final String intervalMs) {
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put(
+        SystemConstant.SQL_DIALECT_KEY,
+        tableModel
+            ? SystemConstant.SQL_DIALECT_TABLE_VALUE
+            : SystemConstant.SQL_DIALECT_TREE_VALUE);
+    if (intervalMs != null) {
+      attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, 
intervalMs);
+    }
+    return new TopicMeta("recycle_topic", 1L, attributes);
+  }
+
   @Test
   public void testColumnFilterRefreshSkippedForOwnerOnlyUpdate() {
     final TopicMeta oldMeta = createTableTopicMeta("column_name = 
\"temperature\"", "db", "t1");
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueueRecycleIntervalTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueueRecycleIntervalTest.java
new file mode 100644
index 00000000000..3aa26ed54b1
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueueRecycleIntervalTest.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.subscription.broker;
+
+import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import 
org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext;
+import 
org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponseType;
+import org.apache.iotdb.rpc.subscription.payload.poll.TerminationPayload;
+import org.apache.iotdb.session.subscription.util.PollTimer;
+
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class SubscriptionPrefetchingQueueRecycleIntervalTest {
+
+  @Test
+  public void testRecycleIntervalForV1Queue() throws Exception {
+    verifyRecycleInterval(false);
+  }
+
+  @Test
+  public void testRecycleIntervalForV2Queue() throws Exception {
+    verifyRecycleInterval(true);
+  }
+
+  private static void verifyRecycleInterval(final boolean useV2) throws 
Exception {
+    final String topicName = "ordinary_recycle_topic_" + useV2;
+    final int originalInterval =
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .getSubscriptionRecycleUncommittedEventIntervalMs();
+    final SubscriptionBlockingPendingQueue inputQueue =
+        mock(SubscriptionBlockingPendingQueue.class);
+    when(inputQueue.isEmpty()).thenReturn(true);
+    final SubscriptionPrefetchingQueue queue =
+        new SubscriptionPrefetchingTabletQueue(
+            "recycle_group", topicName, inputQueue, new AtomicLong());
+    try {
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(600000);
+      updateInterval(topicName, "1200000");
+      final SubscriptionEvent event =
+          new SubscriptionEvent(
+              SubscriptionPollResponseType.TERMINATION.getType(),
+              new TerminationPayload(),
+              new SubscriptionCommitContext(1, 1, topicName, "recycle_group", 
1));
+      assertTrue(event.pollable(0L));
+      queue.prefetchEvent(event);
+      assertSame(event, poll(queue, useV2));
+
+      final Field timestampField = 
SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp");
+      timestampField.setAccessible(true);
+      ((AtomicLong) timestampField.get(event)).set(System.currentTimeMillis() 
- 900000L);
+      assertTrue(event.pollable());
+      executePrefetch(queue, useV2);
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(0, queue.getPrefetchedEventCount());
+
+      for (final long intervalMs : new long[] {0L, -1L, Long.MIN_VALUE}) {
+        updateInterval(topicName, Long.toString(intervalMs));
+        executePrefetch(queue, useV2);
+        assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+        assertEquals(0, queue.getPrefetchedEventCount());
+        assertEquals(0L, event.getNackCount());
+      }
+
+      updateInterval(topicName, null);
+      for (final int intervalMs : new int[] {0, -1, Integer.MIN_VALUE}) {
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .setSubscriptionRecycleUncommittedEventIntervalMs(intervalMs);
+        assertFalse(event.pollable());
+        executePrefetch(queue, useV2);
+        assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+        assertEquals(0, queue.getPrefetchedEventCount());
+      }
+
+      updateInterval(topicName, "300000");
+      executePrefetch(queue, useV2);
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(1, queue.getPrefetchedEventCount());
+      assertEquals(1L, event.getNackCount());
+      assertSame(event, poll(queue, useV2));
+
+      updateInterval(topicName, "0");
+      assertTrue(queue.nack("consumer", event.getCommitContext()));
+      executePrefetch(queue, useV2);
+      assertSame(event, poll(queue, useV2));
+      assertTrue(queue.ack("consumer", event.getCommitContext()));
+    } finally {
+      queue.cleanUp();
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(originalInterval);
+    }
+  }
+
+  private static void updateInterval(final String topicName, final String 
interval) {
+    assertNull(
+        SubscriptionAgent.topic()
+            .handleSingleTopicMetaChanges(
+                new TopicMeta(
+                    topicName,
+                    1L,
+                    interval == null
+                        ? Collections.emptyMap()
+                        : Collections.singletonMap(
+                            
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, interval))));
+  }
+
+  private static SubscriptionEvent poll(
+      final SubscriptionPrefetchingQueue queue, final boolean useV2) {
+    return useV2
+        ? queue.pollV2("consumer", new PollTimer(System.currentTimeMillis(), 
0L))
+        : queue.poll("consumer");
+  }
+
+  private static void executePrefetch(
+      final SubscriptionPrefetchingQueue queue, final boolean useV2) {
+    if (useV2) {
+      queue.executePrefetchV2();
+    } else {
+      queue.executePrefetch();
+    }
+  }
+}
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 32f8e5737fd..cc30193647a 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
@@ -23,7 +23,9 @@ import org.apache.iotdb.commons.conf.CommonDescriptor;
 import org.apache.iotdb.commons.consensus.DataRegionId;
 import org.apache.iotdb.commons.memory.AtomicLongMemoryBlock;
 import org.apache.iotdb.commons.memory.IMemoryBlock;
+import org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
 import org.apache.iotdb.commons.request.IConsensusRequest;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
 import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
 import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
 import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
@@ -81,6 +83,7 @@ import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.Consumer;
@@ -2880,6 +2883,163 @@ public class ConsensusPrefetchingQueueTest {
     }
   }
 
+  @Test
+  public void testTopicRecycleIntervalOverridesGlobalAndAppliesUpdates() 
throws Exception {
+    final String topicName = "consensus_recycle_topic";
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final int originalRecycleInterval =
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .getSubscriptionRecycleUncommittedEventIntervalMs();
+    final long originalWatermarkInterval =
+        
CommonDescriptor.getInstance().getConfig().getSubscriptionConsensusWatermarkIntervalMs();
+    final File systemDir = 
temporaryFolder.newFolder("system-topic-recycle-interval");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      CommonDescriptor.getInstance()
+          .getConfig()
+          .setSubscriptionRecycleUncommittedEventIntervalMs(600000);
+      
CommonDescriptor.getInstance().getConfig().setSubscriptionConsensusWatermarkIntervalMs(0L);
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      java.util.Map.of(
+                          SystemConstant.SQL_DIALECT_KEY,
+                          SystemConstant.SQL_DIALECT_TABLE_VALUE,
+                          
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
+                          "1200000"))));
+      final FakeConsensusReqReader reader = new FakeConsensusReqReader();
+      final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(reader);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
+      final ConsensusLogToTabletConverter converter = 
mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenReturn(Collections.singletonList(createTablet()), 
Collections.emptyList());
+      when(converter.getDatabaseName()).thenReturn("db");
+      when(converter.isTableModel()).thenReturn(true);
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              topicName,
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              new DataRegionId(1),
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  topicName,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              newCommitManager(systemDir),
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      queue.setSubscriptionMemoryManager(
+          new SubscriptionMemoryManager(createTablet().ramBytesUsed() * 2L));
+      reader.currentSearchIndex = 2L;
+      assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+      assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+      assertNull(queue.poll("oldConsumer"));
+      queue.drivePrefetchOnce();
+      final SubscriptionEvent event = queue.poll("oldConsumer");
+      assertNotNull(event);
+      final Field timestampField = 
SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp");
+      timestampField.setAccessible(true);
+      ((AtomicLong) timestampField.get(event)).set(System.currentTimeMillis() 
- 900000L);
+      assertTrue(event.pollable());
+
+      final PrefetchRoundResult result = queue.drivePrefetchOnce();
+      assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(0, queue.getPrefetchedEventCount());
+      assertEquals(PrefetchRoundResult.Type.RESCHEDULE_LATER, 
result.getType());
+      assertEquals(1200000L, result.getDelayMs());
+
+      for (final long intervalMs : new long[] {0L, -1L, Long.MIN_VALUE}) {
+        assertNull(
+            SubscriptionAgent.topic()
+                .handleSingleTopicMetaChanges(
+                    new TopicMeta(
+                        topicName,
+                        1L,
+                        java.util.Map.of(
+                            SystemConstant.SQL_DIALECT_KEY,
+                            SystemConstant.SQL_DIALECT_TABLE_VALUE,
+                            
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
+                            Long.toString(intervalMs)))));
+        assertEquals(PrefetchRoundResult.Type.DORMANT, 
queue.drivePrefetchOnce().getType());
+        assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+        assertEquals(0, queue.getPrefetchedEventCount());
+        assertEquals(0L, event.getNackCount());
+      }
+
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      java.util.Map.of(
+                          SystemConstant.SQL_DIALECT_KEY,
+                          SystemConstant.SQL_DIALECT_TABLE_VALUE))));
+      for (final int intervalMs : new int[] {0, -1, Integer.MIN_VALUE}) {
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .setSubscriptionRecycleUncommittedEventIntervalMs(intervalMs);
+        assertFalse(event.pollable());
+        assertEquals(PrefetchRoundResult.Type.DORMANT, 
queue.drivePrefetchOnce().getType());
+        assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
+        assertEquals(0, queue.getPrefetchedEventCount());
+      }
+
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      java.util.Map.of(
+                          SystemConstant.SQL_DIALECT_KEY,
+                          SystemConstant.SQL_DIALECT_TABLE_VALUE,
+                          
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
+                          "300000"))));
+      queue.drivePrefetchOnce();
+      assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
+      assertEquals(1, queue.getPrefetchedEventCount());
+      assertSame(event, queue.poll("newConsumer"));
+      assertEquals(1L, event.getNackCount());
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      java.util.Map.of(
+                          SystemConstant.SQL_DIALECT_KEY,
+                          SystemConstant.SQL_DIALECT_TABLE_VALUE,
+                          
TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY,
+                          "0"))));
+      assertTrue(queue.nack("newConsumer", event.getCommitContext()));
+      queue.drivePrefetchOnce();
+      assertSame(event, queue.poll("newConsumer"));
+      assertTrue(queue.ack("newConsumer", event.getCommitContext()));
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      SubscriptionAgent.topic().handleDropTopic(topicName, true);
+      CommonDescriptor.getInstance()
+          .getConfig()
+          
.setSubscriptionRecycleUncommittedEventIntervalMs(originalRecycleInterval);
+      CommonDescriptor.getInstance()
+          .getConfig()
+          
.setSubscriptionConsensusWatermarkIntervalMs(originalWatermarkInterval);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
   @Test
   public void testLateAckAfterRecycleReleasesQueuedTabletMemory() throws 
Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
@@ -2892,7 +3052,7 @@ public class ConsensusPrefetchingQueueTest {
     try {
       CommonDescriptor.getInstance()
           .getConfig()
-          .setSubscriptionRecycleUncommittedEventIntervalMs(-1);
+          .setSubscriptionRecycleUncommittedEventIntervalMs(1);
       final DataRegionId regionId = new DataRegionId(1);
       final FakeConsensusReqReader reader = new FakeConsensusReqReader();
       final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
@@ -2936,6 +3096,9 @@ public class ConsensusPrefetchingQueueTest {
       assertTrue(queue.getRetainedTabletBytes() > 0L);
       assertEquals(1L, queue.getSubscriptionUncommittedEventCount());
 
+      final Field timestampField = 
SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp");
+      timestampField.setAccessible(true);
+      ((AtomicLong) timestampField.get(event)).set(System.currentTimeMillis() 
- 900000L);
       queue.drivePrefetchOnce();
       assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
       assertEquals(1, queue.getPrefetchedEventCount());
@@ -2967,7 +3130,7 @@ public class ConsensusPrefetchingQueueTest {
     try {
       CommonDescriptor.getInstance()
           .getConfig()
-          .setSubscriptionRecycleUncommittedEventIntervalMs(-1);
+          .setSubscriptionRecycleUncommittedEventIntervalMs(1);
       final DataRegionId regionId = new DataRegionId(1);
       final FakeConsensusReqReader reader = new FakeConsensusReqReader();
       final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
@@ -3011,6 +3174,9 @@ public class ConsensusPrefetchingQueueTest {
       final long retainedBytes = queue.getRetainedTabletBytes();
       assertTrue(retainedBytes > 0L);
 
+      final Field timestampField = 
SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp");
+      timestampField.setAccessible(true);
+      ((AtomicLong) timestampField.get(event)).set(System.currentTimeMillis() 
- 900000L);
       queue.drivePrefetchOnce();
       assertEquals(0L, queue.getSubscriptionUncommittedEventCount());
       assertEquals(1, queue.getPrefetchedEventCount());
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 b229f36d1fe..0a1de7d02f1 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
@@ -2160,6 +2160,19 @@ pipe_all_sinks_rate_limit_bytes_per_second=-1
 ### Subscription Consensus Configuration
 ####################
 
+# Time without a poll or in-flight lease renewal before an uncommitted 
subscription event is
+# recycled for redelivery. Applies to all topic modes and both record and 
TsFile formats.
+# A zero or negative value disables automatic recycling. A topic can override 
this with a long value
+# in milliseconds; zero or negative topic values also disable automatic 
recycling, for example:
+# CREATE TOPIC t WITH ('recycle-uncommitted-event-interval-ms'='1200000');
+# ALTER TOPIC t WITH ('recycle-uncommitted-event-interval-ms'='300000');
+# ALTER TOPIC t WITH ('recycle-uncommitted-event-interval-ms'='0');
+# Disabling automatic recycling still allows explicit NACK redelivery and ACK 
commits.
+# Topics without an override use this server configuration (default: 600 
seconds).
+# effectiveMode: restart
+# Datatype: int, Unit: ms
+subscription_recycle_uncommitted_event_interval_ms=600000
+
 # Maximum fraction of pipe non-floating memory used to cache serialized 
subscription poll responses.
 # Recommended starting value: 0.2, including large-field and large-batch 
workloads. This does not control
 # consensus subscription tablet materialization; size that budget using the 
ninth

Reply via email to