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 378196eaf6c [Subscription] Preserve trailing consensus progress 
broadcasts (#19057)
378196eaf6c is described below

commit 378196eaf6cf9203c972ab9d561c4285ad455773
Author: Caideyipi <[email protected]>
AuthorDate: Fri Oct 30 12:50:20 2026 +0800

    [Subscription] Preserve trailing consensus progress broadcasts (#19057)
    
    * Fix trailing consensus subscription progress broadcasts
    
    * Fix subscription broadcast state lifecycle
    
    * Strengthen subscription broadcast lifecycle test
    
    * Strengthen rejected broadcast schedule assertion
---
 .../ConsensusSubscriptionCommitManager.java        | 141 ++++++++--
 .../ConsensusSubscriptionCommitBroadcastTest.java  | 288 +++++++++++++++++++++
 2 files changed, 409 insertions(+), 20 deletions(-)

diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
index f9a27372d5e..b183ca745f9 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
@@ -79,7 +79,9 @@ import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
 
 /**
  * Manages committed progress for consensus-based subscriptions.
@@ -128,10 +130,12 @@ public class ConsensusSubscriptionCommitManager {
   /** Rate-limiting: last broadcast timestamp per key. */
   private final Map<String, Long> lastBroadcastTime = new 
ConcurrentHashMap<>();
 
-  /** Single-threaded executor for fire-and-forget broadcasts. */
-  private final ExecutorService broadcastExecutor =
-      IoTDBThreadPoolFactory.newSingleThreadExecutor(
-          ThreadName.SUBSCRIPTION_CONSENSUS_PROGRESS_BROADCASTER.getName());
+  /** At most one scheduled broadcast per writer and commit-state generation. 
*/
+  private final Map<String, ConsensusSubscriptionCommitState> 
pendingBroadcasts =
+      new ConcurrentHashMap<>();
+
+  private final ScheduledExecutorService broadcastExecutor;
+  private final LongSupplier broadcastClock;
 
   /** Key: versioned, encoded (consumerGroupId, topicName, regionId) -> 
progress tracking state. */
   private final Map<String, ConsensusSubscriptionCommitState> commitStates =
@@ -174,6 +178,19 @@ public class ConsensusSubscriptionCommitManager {
   }
 
   ConsensusSubscriptionCommitManager(final ConfigNodeProgressFetcher 
configNodeProgressFetcher) {
+    this(
+        configNodeProgressFetcher,
+        IoTDBThreadPoolFactory.newSingleThreadScheduledExecutor(
+            ThreadName.SUBSCRIPTION_CONSENSUS_PROGRESS_BROADCASTER.getName()),
+        System::currentTimeMillis);
+  }
+
+  ConsensusSubscriptionCommitManager(
+      final ConfigNodeProgressFetcher configNodeProgressFetcher,
+      final ScheduledExecutorService broadcastExecutor,
+      final LongSupplier broadcastClock) {
+    this.broadcastExecutor = broadcastExecutor;
+    this.broadcastClock = broadcastClock;
     this.configNodeProgressFetcher =
         Objects.nonNull(configNodeProgressFetcher)
             ? configNodeProgressFetcher
@@ -307,7 +324,7 @@ public class ConsensusSubscriptionCommitManager {
           final CommitStateKey stateKey =
               getCommitStateKey(setupSnapshot.consumerGroupId, topicName, 
entry.getKey());
           final StateSetupSnapshot stateSnapshot = entry.getValue();
-          commitStates.put(
+          replaceCommitState(
               stateKey.encodedStateKey,
               new ConsensusSubscriptionCommitState(
                   stateKey.regionIdStr,
@@ -363,7 +380,7 @@ public class ConsensusSubscriptionCommitManager {
           || preservedRegionIds.contains(entry.getValue().regionIdStr)) {
         continue;
       }
-      commitStates.remove(entry.getKey());
+      removeCommitState(entry.getKey());
       pendingInitialProgressKeys.remove(entry.getKey());
       pendingNewRegionProgressKeys.remove(entry.getKey());
       configNodeProgressAvailableKeys.remove(entry.getKey());
@@ -709,7 +726,7 @@ public class ConsensusSubscriptionCommitManager {
     final CommitStateKey stateKey =
         getCommitStateKey(consumerGroupId, topicName, regionId.toString());
     recoverTopicStatesIfNeeded(stateKey);
-    commitStates.remove(stateKey.encodedStateKey);
+    removeCommitState(stateKey.encodedStateKey);
     commitStateKeys.remove(stateKey.encodedStateKey);
     pendingInitialProgressKeys.remove(stateKey.encodedStateKey);
     pendingNewRegionProgressKeys.remove(stateKey.encodedStateKey);
@@ -732,7 +749,7 @@ public class ConsensusSubscriptionCommitManager {
     while (keyIterator.hasNext()) {
       final Map.Entry<String, CommitStateKey> entry = keyIterator.next();
       if (entry.getValue().sameTopic(consumerGroupId, topicName)) {
-        commitStates.remove(entry.getKey());
+        removeCommitState(entry.getKey());
         pendingInitialProgressKeys.remove(entry.getKey());
         pendingNewRegionProgressKeys.remove(entry.getKey());
         configNodeProgressAvailableKeys.remove(entry.getKey());
@@ -854,8 +871,8 @@ public class ConsensusSubscriptionCommitManager {
   // ======================== Progress Broadcast (Leader → Follower) 
========================
 
   /**
-   * Broadcasts committed progress to followers if enough time has elapsed 
since the last broadcast
-   * for this key. The broadcast is async and fire-and-forget.
+   * Coalesces committed progress while throttling broadcasts. The latest ACK 
is sent even when no
+   * further writes or ACKs arrive after the throttle window.
    */
   private void maybeBroadcast(
       final String key,
@@ -868,23 +885,105 @@ public class ConsensusSubscriptionCommitManager {
       return;
     }
     final String broadcastKey = buildBroadcastKey(key, committedWriterId);
-    final long now = System.currentTimeMillis();
-    final Long last = lastBroadcastTime.get(broadcastKey);
-    if (last != null && now - last < MIN_BROADCAST_INTERVAL_MS) {
+    final ConsensusSubscriptionCommitState state = commitStates.get(key);
+    if (Objects.isNull(state)) {
       return;
     }
-    lastBroadcastTime.put(broadcastKey, now);
-    broadcastExecutor.submit(
-        () ->
-            doBroadcast(
-                consumerGroupId, topicName, regionId, committedWriterProgress, 
committedWriterId));
+    synchronized (lastBroadcastTime) {
+      if (commitStates.get(key) != state) {
+        return;
+      }
+      if (pendingBroadcasts.get(broadcastKey) == state) {
+        return;
+      }
+      final Long last = lastBroadcastTime.get(broadcastKey);
+      final long delay =
+          Objects.isNull(last)
+              ? 0L
+              : Math.max(0L, MIN_BROADCAST_INTERVAL_MS - 
(broadcastClock.getAsLong() - last));
+      pendingBroadcasts.put(broadcastKey, state);
+      try {
+        broadcastExecutor.schedule(
+            () -> {
+              synchronized (state.broadcastLifecycleLock) {
+                final WriterProgress latestProgress;
+                synchronized (lastBroadcastTime) {
+                  if (!pendingBroadcasts.remove(broadcastKey, state)
+                      || commitStates.get(key) != state) {
+                    return;
+                  }
+                  lastBroadcastTime.put(broadcastKey, 
broadcastClock.getAsLong());
+                  latestProgress =
+                      state
+                          .getCommittedRegionProgress()
+                          .getWriterPositions()
+                          .get(committedWriterId);
+                }
+                if (Objects.nonNull(latestProgress)) {
+                  doBroadcast(
+                      consumerGroupId, topicName, regionId, latestProgress, 
committedWriterId);
+                }
+              }
+            },
+            delay,
+            TimeUnit.MILLISECONDS);
+      } catch (final RuntimeException e) {
+        pendingBroadcasts.remove(broadcastKey, state);
+        throw e;
+      }
+    }
+  }
+
+  private void removeCommitState(final String stateKey) {
+    while (true) {
+      final ConsensusSubscriptionCommitState state = 
commitStates.get(stateKey);
+      if (Objects.isNull(state)) {
+        return;
+      }
+      synchronized (state.broadcastLifecycleLock) {
+        synchronized (lastBroadcastTime) {
+          if (commitStates.remove(stateKey, state)) {
+            clearBroadcastStateUnderLock(stateKey);
+            return;
+          }
+        }
+      }
+    }
+  }
+
+  private void replaceCommitState(
+      final String stateKey, final ConsensusSubscriptionCommitState 
replacement) {
+    while (true) {
+      final ConsensusSubscriptionCommitState state = 
commitStates.get(stateKey);
+      if (Objects.isNull(state)) {
+        if (Objects.isNull(commitStates.putIfAbsent(stateKey, replacement))) {
+          return;
+        }
+        continue;
+      }
+      synchronized (state.broadcastLifecycleLock) {
+        synchronized (lastBroadcastTime) {
+          if (commitStates.replace(stateKey, state, replacement)) {
+            clearBroadcastStateUnderLock(stateKey);
+            return;
+          }
+        }
+      }
+    }
+  }
+
+  /** Clears scheduled work and throttle history for one subscription state 
generation. */
+  private void clearBroadcastStateUnderLock(final String stateKey) {
+    final String broadcastKeyPrefix = stateKey + KEY_SEPARATOR;
+    pendingBroadcasts.keySet().removeIf(key -> 
key.startsWith(broadcastKeyPrefix));
+    lastBroadcastTime.keySet().removeIf(key -> 
key.startsWith(broadcastKeyPrefix));
   }
 
   /**
    * Sends committed progress to all follower replicas of the given region. 
Uses the partition cache
    * to discover replica endpoints and skips the local DataNode.
    */
-  private void doBroadcast(
+  void doBroadcast(
       final String consumerGroupId,
       final String topicName,
       final ConsensusGroupId regionId,
@@ -1695,6 +1794,8 @@ public class ConsensusSubscriptionCommitManager {
    */
   public static class ConsensusSubscriptionCommitState {
 
+    private final Object broadcastLifecycleLock = new Object();
+
     private final String regionId;
 
     private final SubscriptionConsensusProgress progress;
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitBroadcastTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitBroadcastTest.java
new file mode 100644
index 00000000000..a138fea012d
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitBroadcastTest.java
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.subscription.broker.consensus;
+
+import org.apache.iotdb.commons.consensus.DataRegionId;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
+import org.apache.iotdb.rpc.subscription.payload.poll.WriterId;
+import org.apache.iotdb.rpc.subscription.payload.poll.WriterProgress;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+public class ConsensusSubscriptionCommitBroadcastTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private static final DataRegionId REGION = new DataRegionId(31);
+  private static final WriterId WRITER = new WriterId(REGION.toString(), 7);
+
+  private final ScheduledExecutorService executor = 
mock(ScheduledExecutorService.class);
+  private final AtomicLong now = new AtomicLong(10_000L);
+  private ConsensusSubscriptionCommitManager manager;
+  private String originalSystemDir;
+
+  @Before
+  public void setUp() throws Exception {
+    originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    IoTDBDescriptor.getInstance()
+        .getConfig()
+        .setSystemDir(temporaryFolder.newFolder().getAbsolutePath());
+    manager =
+        spy(
+            new ConsensusSubscriptionCommitManager(
+                (consumerGroupId, topicName, regionId) ->
+                    
ConsensusSubscriptionCommitManager.ConfigNodeProgressQueryResult.available(
+                        new RegionProgress(Collections.emptyMap())),
+                executor,
+                now::get));
+    doNothing().when(manager).doBroadcast(eq("group"), eq("topic"), 
eq(REGION), any(), any());
+  }
+
+  @After
+  public void tearDown() {
+    IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+  }
+
+  @Test
+  public void testThrottledFinalAckIsBroadcastWithoutAnotherCommit() {
+    commit(WRITER, 1L);
+    final ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+    verify(executor).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    tasks.getValue().run();
+    verify(manager).doBroadcast("group", "topic", REGION, progress(1L), 
WRITER);
+
+    now.addAndGet(100L);
+    commit(WRITER, 2L);
+    commit(WRITER, 3L);
+    verify(executor).schedule(tasks.capture(), eq(4900L), 
eq(TimeUnit.MILLISECONDS));
+    verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), 
eq(TimeUnit.MILLISECONDS));
+
+    now.addAndGet(4900L);
+    tasks.getValue().run();
+    verify(manager).doBroadcast("group", "topic", REGION, progress(3L), 
WRITER);
+    verify(manager, never()).doBroadcast("group", "topic", REGION, 
progress(2L), WRITER);
+  }
+
+  @Test
+  public void testWritersHaveIndependentBroadcasts() {
+    final WriterId otherWriter = new WriterId(REGION.toString(), 8);
+    commit(WRITER, 1L);
+    commit(otherWriter, 1L);
+    final ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+    verify(executor, times(2)).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    tasks.getAllValues().forEach(Runnable::run);
+
+    verify(manager).doBroadcast("group", "topic", REGION, progress(1L), 
WRITER);
+    verify(manager).doBroadcast("group", "topic", REGION, progress(1L), 
otherWriter);
+  }
+
+  @Test
+  public void testRemovedStateCannotBroadcastIntoResubscribedState() {
+    commit(WRITER, 1L);
+    final ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+    verify(executor).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    final Runnable oldTask = tasks.getValue();
+
+    manager.removeState("group", "topic", REGION);
+    commit(WRITER, 2L);
+    verify(executor, times(2)).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    oldTask.run();
+    verify(manager, never()).doBroadcast("group", "topic", REGION, 
progress(1L), WRITER);
+    tasks.getValue().run();
+    verify(manager).doBroadcast("group", "topic", REGION, progress(2L), 
WRITER);
+  }
+
+  @Test
+  public void testResubscribedStateDoesNotInheritBroadcastThrottle() {
+    commit(WRITER, 1L);
+    final ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+    verify(executor).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    tasks.getValue().run();
+
+    now.addAndGet(100L);
+    manager.removeState("group", "topic", REGION);
+    commit(WRITER, 2L);
+
+    verify(executor, times(2)).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    tasks.getValue().run();
+    verify(manager).doBroadcast("group", "topic", REGION, progress(2L), 
WRITER);
+  }
+
+  @Test
+  public void testRemovingStateWaitsForBroadcastAlreadyInProgress() throws 
Exception {
+    final CountDownLatch broadcastStarted = new CountDownLatch(1);
+    final CountDownLatch releaseBroadcast = new CountDownLatch(1);
+    final CountDownLatch removeStarted = new CountDownLatch(1);
+    final AtomicReference<Throwable> broadcastFailure = new 
AtomicReference<>();
+    final AtomicReference<Throwable> removeFailure = new AtomicReference<>();
+    doAnswer(
+            invocation -> {
+              broadcastStarted.countDown();
+              assertTrue(releaseBroadcast.await(30L, TimeUnit.SECONDS));
+              return null;
+            })
+        .when(manager)
+        .doBroadcast(eq("group"), eq("topic"), eq(REGION), any(), eq(WRITER));
+    doAnswer(
+            invocation -> {
+              removeStarted.countDown();
+              return invocation.callRealMethod();
+            })
+        .when(manager)
+        .removeState("group", "topic", REGION);
+
+    commit(WRITER, 1L);
+    final ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+    verify(executor).schedule(tasks.capture(), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    final Thread broadcastThread =
+        new Thread(
+            () -> {
+              try {
+                tasks.getValue().run();
+              } catch (final Throwable t) {
+                broadcastFailure.set(t);
+              }
+            },
+            "broadcast-test");
+    final CountDownLatch removeFinished = new CountDownLatch(1);
+    final Thread removeThread =
+        new Thread(
+            () -> {
+              try {
+                manager.removeState("group", "topic", REGION);
+              } catch (final Throwable t) {
+                removeFailure.set(t);
+              } finally {
+                removeFinished.countDown();
+              }
+            },
+            "remove-state-test");
+    try {
+      broadcastThread.start();
+      assertTrue(broadcastStarted.await(5L, TimeUnit.SECONDS));
+      removeThread.start();
+      assertTrue(removeStarted.await(5L, TimeUnit.SECONDS));
+      assertTrue(
+          waitForThreadBlockedInMethod(
+              removeThread,
+              ConsensusSubscriptionCommitManager.class.getName(),
+              "removeCommitState",
+              5L,
+              TimeUnit.SECONDS));
+    } finally {
+      releaseBroadcast.countDown();
+    }
+    assertTrue(removeFinished.await(5L, TimeUnit.SECONDS));
+    broadcastThread.join(5_000L);
+    removeThread.join(5_000L);
+    assertNull(broadcastFailure.get());
+    assertNull(removeFailure.get());
+    verify(manager).doBroadcast("group", "topic", REGION, progress(1L), 
WRITER);
+  }
+
+  private static boolean waitForThreadBlockedInMethod(
+      final Thread thread,
+      final String className,
+      final String methodName,
+      final long timeout,
+      final TimeUnit unit)
+      throws InterruptedException {
+    final long deadline = System.nanoTime() + unit.toNanos(timeout);
+    while (System.nanoTime() < deadline) {
+      if (thread.getState() == Thread.State.BLOCKED) {
+        for (final StackTraceElement frame : thread.getStackTrace()) {
+          if (className.equals(frame.getClassName()) && 
methodName.equals(frame.getMethodName())) {
+            return true;
+          }
+        }
+      }
+      Thread.sleep(1L);
+    }
+    return false;
+  }
+
+  @Test
+  public void testRejectedScheduleDoesNotSuppressLaterBroadcast() {
+    final AtomicBoolean rejectFirstSchedule = new AtomicBoolean(true);
+    final AtomicReference<Runnable> scheduledTask = new AtomicReference<>();
+    doAnswer(
+            invocation -> {
+              if (rejectFirstSchedule.getAndSet(false)) {
+                throw new RejectedExecutionException();
+              }
+              scheduledTask.set(invocation.getArgument(0));
+              return null;
+            })
+        .when(executor)
+        .schedule(any(Runnable.class), anyLong(), eq(TimeUnit.MILLISECONDS));
+
+    try {
+      commit(WRITER, 1L);
+      throw new AssertionError("Expected the first scheduled broadcast to be 
rejected");
+    } catch (final RejectedExecutionException expected) {
+      // The executor can reject during shutdown; later attempts must not 
remain marked pending.
+    }
+
+    commit(WRITER, 2L);
+    assertNotNull(scheduledTask.get());
+    scheduledTask.get().run();
+
+    verify(executor, times(2)).schedule(any(Runnable.class), eq(0L), 
eq(TimeUnit.MILLISECONDS));
+    verify(manager).doBroadcast("group", "topic", REGION, progress(2L), 
WRITER);
+  }
+
+  private void commit(final WriterId writerId, final long sequence) {
+    manager.recordMapping("group", "topic", REGION, writerId, 
progress(sequence));
+    assertTrue(manager.commit("group", "topic", REGION, writerId, 
progress(sequence)));
+  }
+
+  private static WriterProgress progress(final long sequence) {
+    return new WriterProgress(100L + sequence, sequence);
+  }
+}

Reply via email to