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

lizhimins pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/rocketmq-flink.git


The following commit(s) were added to refs/heads/main by this push:
     new 80a98ac  [ISSUE #151] fix(remoting): make split assignment and 
partition discovery survive direct-connect route races (#152)
80a98ac is described below

commit 80a98acccef1dbefbb87bdd07f90bee6d90d91a1
Author: lizhimins <[email protected]>
AuthorDate: Thu Oct 29 13:59:16 2026 +0800

    [ISSUE #151] fix(remoting): make split assignment and partition discovery 
survive direct-connect route races (#152)
    
    Two silent defects surface when the client talks to NameServers directly
    (open-source deployment, no proxy):
    
    1. Starting offsets are ignored for a random subset of queues.
       RebalancePullImpl.computePullFromWhereWithException is hard-coded to 0,
       so assign() lets the client-internal rebalance thread dispatch a first
       pull from offset 0 while handleSplitsChanges is still seeking to the
       configured starting offset. No Flink-side lock scope can win that race.
       Enforce the starting offset deterministically at the fetch() outlet
       instead: records below max(startingOffset, 0) are dropped there, and
       seek() remains as a traffic optimization.
    
    2. A NameServer outage restarts the job and rewinds offsets. The periodic
       partition discovery flattened per-topic route fetch failures into an
       empty queue set; diffed against the allocated set that revokes every
       split, and the queues returning after recovery are re-initialized from
       scratch (observed: +150 replayed records). A failed fetch says nothing
       about the actual route: exclude failed topics from the round, skip the
       round when every topic failed, and keep the current assignment when an
       empty result arrives while queues are allocated. Genuine deletions
       surface again on later rounds.
    
    Also document that EXACTLY_ONCE is not supported on proxy-fronted
    endpoints (transaction state lives in per-proxy-process memory and is
    silently dropped), while direct deployments do support it.
    
    Cluster verification of the outage scenario after the fix: job RUNNING
    throughout the 60s NameServer stop, zero split decreases, zero replay
    (sink grew exactly by the second wave), producer recovered within 150s
    of the NameServer coming back.
---
 docs/remoting-connector.md                         |  27 ++-
 .../enumerator/RocketMQSourceEnumerator.java       |  50 ++++--
 .../source/reader/RocketMQSplitReader.java         |  11 ++
 ...etMQSourceEnumeratorDiscoveryToleranceTest.java | 182 +++++++++++++++++++++
 4 files changed, 247 insertions(+), 23 deletions(-)

diff --git a/docs/remoting-connector.md b/docs/remoting-connector.md
index c4c960a..5f555cd 100644
--- a/docs/remoting-connector.md
+++ b/docs/remoting-connector.md
@@ -50,13 +50,24 @@ RocketMQSink<String> sink = RocketMQSink.<String>builder()
 - `AT_LEAST_ONCE` uses async sends with a pending counter and a flush barrier 
on checkpoint.
 - `EXACTLY_ONCE` uses `TransactionMQProducer` two-phase commit driven by Flink 
checkpoints;
   set `rocketmq.sink.transaction.timeout` larger than the checkpoint interval.
-- `EXACTLY_ONCE` also requires the **checkpoint interval to stay well below 
the server-side
-  transaction state TTL**. A proxy keeps the transaction state of a half 
message in its own memory
-  and drops it after `transactionDataExpireMillis` (30 seconds by default). 
Flink only commits at
-  the end of a checkpoint, so the interval plus the checkpoint duration is the 
delay that state has
-  to survive; a commit arriving after it is discarded while still being 
answered with a success
-  code, so those records never appear and no error is raised. Raise the 
server-side TTL if the job
-  needs longer checkpoints.
+
+> **`EXACTLY_ONCE` is NOT supported when the endpoints point at a proxy** 
(Alibaba Cloud
+> RocketMQ 5.x commercial instances, or any deployment where clients reach 
brokers through a
+> proxy). Use `AT_LEAST_ONCE` with idempotent downstream consumers in those 
deployments.
+>
+> Why: a proxy keeps the transaction state of a half message only in the 
memory of the proxy
+> process that received it, and drops it after `transactionDataExpireMillis` 
(30 seconds by
+> default). Flink commits at the end of a checkpoint, so a commit that lands 
on a different
+> proxy pod, or arrives after the TTL, finds no state; the proxy then maps
+> `TRANSACTION_DATA_NOT_FOUND` to a **success** response and drops it without 
a single log
+> line — the job sees successful commits while the records never become 
visible, and nothing
+> surfaces anywhere. The broker-side transaction check does not rescue these 
half messages
+> through the proxy path either. The failure is silent by construction, so it 
cannot be
+> mitigated by tuning the checkpoint interval.
+>
+> `EXACTLY_ONCE` **is supported on direct deployments** (endpoints = 
nameserver addresses, the
+> connector talks to brokers directly): the transaction state lives on the 
broker that holds
+> the half message and is persisted, so commits succeed regardless of the 
checkpoint interval.
 
 ## Client options (`rocketmq.client.*`)
 
@@ -128,7 +139,7 @@ group or a missing subscription relationship shows up.
 | `rocketmq.sink.topic` | String | (none) | Topic, required for SQL |
 | `rocketmq.sink.group` | String | `PID-flink-producer` | Producer group |
 | `rocketmq.sink.tag` | String | (none) | Static message tag |
-| `rocketmq.sink.delivery.guarantee` | String | `AT_LEAST_ONCE` | `NONE` / 
`AT_LEAST_ONCE` / `EXACTLY_ONCE` |
+| `rocketmq.sink.delivery.guarantee` | String | `AT_LEAST_ONCE` | `NONE` / 
`AT_LEAST_ONCE` / `EXACTLY_ONCE` (`EXACTLY_ONCE` is **not supported via a proxy 
endpoint**, see the delivery-semantics note above) |
 | `rocketmq.sink.transaction.timeout` | Long | 900 (s) | Transaction timeout 
for EXACTLY_ONCE |
 | `rocketmq.sink.send.timeout` | Long | 5000 | Send timeout (ms) |
 | `rocketmq.sink.send.retry.times` | Integer | 3 | Send retry times |
diff --git 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumerator.java
 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumerator.java
index 12e31f7..fb9841e 100644
--- 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumerator.java
+++ 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumerator.java
@@ -62,7 +62,6 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
 /** The enumerator class for RocketMQ source. */
 @Internal
@@ -281,20 +280,30 @@ public class RocketMQSourceEnumerator
                                         .getString(RocketMQSourceOptions.TOPIC)
                                         
.split(RocketMQSourceOptions.TOPIC_SEPARATOR)));
 
-        return topicSet.stream()
-                .flatMap(
-                        topic -> {
-                            try {
-                                return 
consumer.fetchMessageQueues(topic).get().stream();
-                            } catch (Exception e) {
-                                log.error(
-                                        "Request topic route for service 
discovery error, topic={}",
-                                        topic,
-                                        e);
-                            }
-                            return Stream.empty();
-                        })
-                .collect(Collectors.toSet());
+        Set<MessageQueue> fetchedQueues = new HashSet<>();
+        Set<String> failedTopics = new HashSet<>();
+        for (String topic : topicSet) {
+            try {
+                fetchedQueues.addAll(consumer.fetchMessageQueues(topic).get());
+            } catch (Exception e) {
+                failedTopics.add(topic);
+                log.error("Request topic route for service discovery error, 
topic={}", topic, e);
+            }
+        }
+        if (!failedTopics.isEmpty()) {
+            // A failed fetch says nothing about the current route: the topic 
may be perfectly
+            // healthy behind an unreachable nameserver. Reporting its queues 
as gone would
+            // revoke and later re-initialize their splits (mass rebalance 
plus an offset
+            // rewind once the nameserver is back), so failed topics are 
excluded from this
+            // round instead, and a round where every topic failed is skipped 
entirely.
+            log.warn(
+                    "Skipping partition decrease evaluation for topics whose 
route fetch failed: {}",
+                    failedTopics);
+            if (failedTopics.size() == topicSet.size()) {
+                return null;
+            }
+        }
+        return fetchedQueues;
     }
 
     // This method should only be invoked in the coordinator executor thread.
@@ -305,6 +314,17 @@ public class RocketMQSourceEnumerator
         if (latestSet == null) {
             return;
         }
+        if (latestSet.isEmpty() && !allocatedSet.isEmpty()) {
+            // An empty discovery result while queues are allocated is 
indistinguishable from
+            // "every route fetch failed" and almost never a real route 
deletion; treating it
+            // as a decrease would revoke the whole assignment. Real deletions 
surface again
+            // on later rounds and are handled together with the rest of the 
change result.
+            log.warn(
+                    "Partition discovery returned an empty queue set while {} 
queues are allocated;"
+                            + " keeping the current assignment for this 
round.",
+                    allocatedSet.size());
+            return;
+        }
 
         final SourceChangeResult sourceChangeResult = 
getSourceChangeResult(latestSet);
         if (sourceChangeResult.isEmpty()) {
diff --git 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/reader/RocketMQSplitReader.java
 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/reader/RocketMQSplitReader.java
index 294b2d4..1ee1fb3 100644
--- 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/reader/RocketMQSplitReader.java
+++ 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/connector/rocketmq/source/reader/RocketMQSplitReader.java
@@ -186,6 +186,17 @@ public class RocketMQSplitReader<T> implements 
SplitReader<MessageView, RocketMQ
                 rocketmqSourceReaderMetrics.setCurrentOffset(
                         messageQueue, messageView.getQueueOffset() + 1);
                 
rocketmqSourceReaderMetrics.recordBytesConsumed(messageView.getStoreSize());
+                // The LitePull client starts the first pull of a freshly 
assigned queue at
+                // offset 0 
(RebalancePullImpl.computePullFromWhereWithException is hard-coded
+                // to 0), and the seek() issued by handleSplitsChanges races 
with that first
+                // pull on the client's rebalance/pull threads. Dropping 
everything below the
+                // split's starting offset here is what makes the configured 
start position
+                // deterministic regardless of who wins that race.
+                Tuple2<Long, Long> splitOffsets = 
currentOffsetTable.get(messageQueue);
+                if (splitOffsets != null
+                        && messageView.getQueueOffset() < 
Math.max(splitOffsets.f0, 0L)) {
+                    continue;
+                }
                 if (!matchesFilter(messageView)) {
                     continue;
                 }
diff --git 
a/flink-connector-rocketmq/src/test/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumeratorDiscoveryToleranceTest.java
 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumeratorDiscoveryToleranceTest.java
new file mode 100644
index 0000000..42a750b
--- /dev/null
+++ 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/connector/rocketmq/source/enumerator/RocketMQSourceEnumeratorDiscoveryToleranceTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.flink.connector.rocketmq.source.enumerator;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.connector.rocketmq.source.InnerConsumer;
+import org.apache.flink.connector.rocketmq.source.RocketMQSourceOptions;
+import 
org.apache.flink.connector.rocketmq.source.enumerator.offset.OffsetsSelector;
+
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * A route fetch failure must never be interpreted as "those queues are gone": 
during a nameserver
+ * outage the topic route is unchanged, and revoking the whole assignment 
would cause a mass
+ * rebalance plus an offset rewind once the nameserver is back.
+ */
+public class RocketMQSourceEnumeratorDiscoveryToleranceTest {
+
+    private static final String BROKER = "broker-a";
+
+    @Test
+    public void allTopicsFetchFailedSkipsDiscoveryRoundTest() throws Exception 
{
+        InnerConsumer consumer = mock(InnerConsumer.class);
+        when(consumer.fetchMessageQueues("t1"))
+                .thenReturn(failedFuture(new RuntimeException("nameserver 
unreachable")));
+        when(consumer.fetchMessageQueues("t2"))
+                .thenReturn(failedFuture(new RuntimeException("nameserver 
unreachable")));
+        RocketMQSourceEnumerator enumerator = newEnumerator(consumer, "t1;t2");
+
+        assertThat(invokeRequestServiceDiscovery(enumerator)).isNull();
+    }
+
+    @Test
+    public void partialFetchFailureKeepsFailedTopicQueuesTest() throws 
Exception {
+        InnerConsumer consumer = mock(InnerConsumer.class);
+        when(consumer.fetchMessageQueues("t1"))
+                .thenReturn(
+                        CompletableFuture.completedFuture(
+                                Arrays.asList(queue("t1", 0), queue("t1", 
1))));
+        when(consumer.fetchMessageQueues("t2"))
+                .thenReturn(failedFuture(new RuntimeException("nameserver 
unreachable")));
+        RocketMQSourceEnumerator enumerator = newEnumerator(consumer, "t1;t2");
+
+        Set<MessageQueue> discovered = 
invokeRequestServiceDiscovery(enumerator);
+
+        assertThat(discovered).containsExactlyInAnyOrder(queue("t1", 0), 
queue("t1", 1));
+    }
+
+    @Test
+    public void successfulDiscoveryReturnsAllQueuesTest() throws Exception {
+        InnerConsumer consumer = mock(InnerConsumer.class);
+        when(consumer.fetchMessageQueues("t1"))
+                .thenReturn(
+                        CompletableFuture.completedFuture(
+                                Arrays.asList(queue("t1", 0), queue("t1", 
1))));
+        when(consumer.fetchMessageQueues("t2"))
+                
.thenReturn(CompletableFuture.completedFuture(Arrays.asList(queue("t2", 0))));
+        RocketMQSourceEnumerator enumerator = newEnumerator(consumer, "t1;t2");
+
+        Set<MessageQueue> discovered = 
invokeRequestServiceDiscovery(enumerator);
+
+        assertThat(discovered)
+                .containsExactlyInAnyOrder(queue("t1", 0), queue("t1", 1), 
queue("t2", 0));
+    }
+
+    @Test
+    public void emptyDiscoveryWithAllocatedQueuesKeepsAssignmentTest() throws 
Exception {
+        InnerConsumer consumer = mock(InnerConsumer.class);
+        RocketMQSourceEnumerator enumerator = newEnumerator(consumer, "t1");
+        setAllocatedSet(enumerator, new HashSet<>(Arrays.asList(queue("t1", 
0), queue("t1", 1))));
+
+        Method handle =
+                RocketMQSourceEnumerator.class.getDeclaredMethod(
+                        "handleSourceQueueChange", Set.class, Throwable.class);
+        handle.setAccessible(true);
+        // Must return without touching the split assignment or scheduling any 
re-initialization.
+        handle.invoke(enumerator, new HashSet<MessageQueue>(), null);
+
+        assertThat(allocatedSetOf(enumerator)).hasSize(2);
+    }
+
+    @Test
+    public void emptyLatestSetAloneWouldRevokeEverythingTest() throws 
Exception {
+        // Documents the hazard the guards above prevent: diffing an empty 
route against the
+        // allocated set reports every queue as decreased.
+        InnerConsumer consumer = mock(InnerConsumer.class);
+        RocketMQSourceEnumerator enumerator = newEnumerator(consumer, "t1");
+        setAllocatedSet(enumerator, new HashSet<>(Arrays.asList(queue("t1", 
0), queue("t1", 1))));
+
+        Method diff =
+                RocketMQSourceEnumerator.class.getDeclaredMethod(
+                        "getSourceChangeResult", Set.class);
+        diff.setAccessible(true);
+        Object changeResult = diff.invoke(enumerator, new 
HashSet<MessageQueue>());
+
+        Field decreaseField = 
changeResult.getClass().getDeclaredField("decreaseSet");
+        decreaseField.setAccessible(true);
+        assertThat((Collection<?>) decreaseField.get(changeResult)).hasSize(2);
+    }
+
+    private static RocketMQSourceEnumerator newEnumerator(InnerConsumer 
consumer, String topics)
+            throws Exception {
+        Configuration configuration = new Configuration();
+        configuration.setString(RocketMQSourceOptions.TOPIC, topics);
+        RocketMQSourceEnumerator enumerator =
+                new RocketMQSourceEnumerator(
+                        OffsetsSelector.earliest(),
+                        OffsetsSelector.latest(),
+                        Boundedness.CONTINUOUS_UNBOUNDED,
+                        configuration,
+                        null);
+        Field consumerField = 
RocketMQSourceEnumerator.class.getDeclaredField("consumer");
+        consumerField.setAccessible(true);
+        consumerField.set(enumerator, consumer);
+        return enumerator;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Set<MessageQueue> 
invokeRequestServiceDiscovery(RocketMQSourceEnumerator e)
+            throws Exception {
+        Method method = 
RocketMQSourceEnumerator.class.getDeclaredMethod("requestServiceDiscovery");
+        method.setAccessible(true);
+        return (Set<MessageQueue>) method.invoke(e);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void setAllocatedSet(RocketMQSourceEnumerator e, 
Set<MessageQueue> queues)
+            throws Exception {
+        Field field = 
RocketMQSourceEnumerator.class.getDeclaredField("allocatedSet");
+        field.setAccessible(true);
+        ((java.util.Map<MessageQueue, Byte>) field.get(e))
+                .putAll(queues.stream().collect(Collectors.toMap(q -> q, q -> 
(byte) 0)));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Set<MessageQueue> allocatedSetOf(RocketMQSourceEnumerator 
e) throws Exception {
+        Field field = 
RocketMQSourceEnumerator.class.getDeclaredField("allocatedSet");
+        field.setAccessible(true);
+        return ((java.util.Map<MessageQueue, Byte>) field.get(e)).keySet();
+    }
+
+    private static MessageQueue queue(String topic, int queueId) {
+        return new MessageQueue(topic, BROKER, queueId);
+    }
+
+    private static CompletableFuture<Collection<MessageQueue>> 
failedFuture(Throwable t) {
+        CompletableFuture<Collection<MessageQueue>> future = new 
CompletableFuture<>();
+        future.completeExceptionally(t);
+        return future;
+    }
+}

Reply via email to