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 1cbb6da  [ISSUE #141] fix(legacy): stop the legacy source rewinding 
and surface legacy sink async failures (#148)
1cbb6da is described below

commit 1cbb6daa046e275c2b43f6ff2a15ff3aebf5ccd5
Author: lizhimins <[email protected]>
AuthorDate: Mon Oct 26 20:38:37 2026 +0800

    [ISSUE #141] fix(legacy): stop the legacy source rewinding and surface 
legacy sink async failures (#148)
    
    The legacy source read its next position back from 
DefaultLitePullConsumer#committed
    after every poll. Auto-commit is off by default, so the broker-side commit 
never
    advances and every poll round re-seeked to the same offset and re-emitted 
the whole
    topic: an acceptance run on a commercial 5.5.1 cluster turned 101 source 
messages
    into 120065 sink messages (118560 duplicates) while the job stayed RUNNING 
with no
    restarts and no errors. Book the position from the messages a poll drains 
instead,
    per queue, because poll serves every assigned queue.
    
    Seeking, polling and booking now happen under one lock. poll drains all 
assigned
    queues, so a seek issued by one queue thread after another thread's poll 
rewound
    that queue and duplicated the messages it had just drained; the first 
version of
    this fix still showed 30 duplicates for that reason.
    
    initOffsets also stored committed() verbatim, which is -1 for a group that 
has
    never committed. The EARLIEST and LATEST branches had no fallback for it 
(only
    GROUP_OFFSETS did), so the first seek failed with "Seek offset illegal" and 
the
    source gave up, finishing the job having read nothing. Fall back to the 
position
    the preceding seek left in the local offset store.
    
    The legacy sink parked the first async send failure and rethrew it from 
finish(),
    and invoke() checks it too, but a burst of failures followed by an idle 
stream
    never reaches another invoke, and snapshotState did not check at all: 
checkpoints
    completed over the lost records and stop-with-savepoint succeeded. Check the
    parked failure in snapshotState as well, so a checkpoint cannot commit a 
loss.
    
    Verified on a commercial 5.5.1 cluster with Flink 1.20.3: legacy source + 
legacy
    sink, 100 messages, duplicates 118560 -> 0; legacy sink in pure async mode 
with
    every send rejected, task failures 0 -> 3 with the IOException raised from
    snapshotState, i.e. the loss is now loud instead of silent.
---
 .../connectors/rocketmq/RocketMQSink.java          |  5 ++
 .../rocketmq/RocketMQSourceFunction.java           | 90 ++++++++++++++++------
 .../rocketmq/RocketMQSinkAsyncFailureTest.java     | 90 ++++++++++++++++++++++
 .../sourceFunction/RocketMQSourceFunctionTest.java | 66 ++++++++++++++++
 4 files changed, 229 insertions(+), 22 deletions(-)

diff --git 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSink.java
 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSink.java
index f6cae6e..9f76290 100644
--- 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSink.java
+++ 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSink.java
@@ -258,6 +258,11 @@ public class RocketMQSink extends 
RichSinkFunction<Message> implements Checkpoin
     @Override
     public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
         flushSync();
+        // A checkpoint must not complete over records whose async send 
already failed.
+        // invoke() only surfaces a parked failure when the next record 
arrives, so a burst of
+        // failures followed by an idle stream would otherwise be checkpointed 
as delivered:
+        // the job keeps running, every checkpoint succeeds, and the records 
are gone.
+        checkAsyncSendException();
     }
 
     @Override
diff --git 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSourceFunction.java
 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSourceFunction.java
index 2fe7f98..56d8aac 100644
--- 
a/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSourceFunction.java
+++ 
b/flink-connector-rocketmq/src/main/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSourceFunction.java
@@ -49,6 +49,7 @@ import org.apache.commons.collections.map.LinkedMap;
 import org.apache.commons.lang.Validate;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.rocketmq.client.consumer.DefaultLitePullConsumer;
+import org.apache.rocketmq.client.consumer.store.ReadOffsetType;
 import org.apache.rocketmq.client.exception.MQClientException;
 import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageQueue;
@@ -296,31 +297,61 @@ public class RocketMQSourceFunction<OUT> extends 
RichParallelSourceFunction<OUT>
 
     private void pollAndEmit(MessageQueue mq, SourceContext<OUT> context, int 
pullBatchSize)
             throws Exception {
-        Long offset = offsetTable.get(mq);
         consumer.setPullBatchSize(pullBatchSize);
-        consumer.seek(mq, offset);
-
-        List<MessageExt> messages =
-                consumer.poll(
-                        RocketMQUtils.getInteger(
-                                props,
-                                RocketMQConfig.CONSUMER_TIMEOUT,
-                                RocketMQConfig.DEFAULT_CONSUMER_TIMEOUT));
-
-        boolean found = CollectionUtils.isNotEmpty(messages);
-        if (found) {
-            emitMessages(messages, context);
-        }
-
+        List<MessageExt> messages;
+        // seek, poll and position booking have to be atomic across the 
per-queue threads:
+        // poll drains every assigned queue, so a seek issued from one thread 
after another
+        // thread's poll rewinds that queue and re-emits messages it already 
drained.
         synchronized (checkPointLock) {
-            updateMessageQueueOffset(mq, consumer.committed(mq));
+            Long offset = offsetTable.get(mq);
+            if (offset != null && offset >= 0) {
+                consumer.seek(mq, offset);
+            }
+            messages =
+                    consumer.poll(
+                            RocketMQUtils.getInteger(
+                                    props,
+                                    RocketMQConfig.CONSUMER_TIMEOUT,
+                                    RocketMQConfig.DEFAULT_CONSUMER_TIMEOUT));
+            if (CollectionUtils.isNotEmpty(messages)) {
+                advanceConsumedOffsets(messages, mq);
+            }
         }
 
-        if (!found) {
+        if (CollectionUtils.isNotEmpty(messages)) {
+            emitMessages(messages, context);
+        } else {
             
RetryUtil.waitForMs(RocketMQConfig.DEFAULT_CONSUMER_DELAY_WHEN_MESSAGE_NOT_FOUND);
         }
     }
 
+    /**
+     * Books the next offset to read for every queue this poll drained. {@code 
poll} serves all
+     * assigned queues, so each message is booked under its own queue rather 
than under the queue
+     * this thread was handed.
+     *
+     * <p>The position must come from the messages themselves. Reading it back 
from {@code
+     * DefaultLitePullConsumer#committed} rewinds the source whenever the 
broker-side commit does
+     * not advance — auto-commit is off by default — which makes every poll 
round re-seek to the
+     * same position and re-emit the whole topic, multiplying the output by 
the number of rounds
+     * while every health signal stays green.
+     */
+    private void advanceConsumedOffsets(List<MessageExt> messages, 
MessageQueue assigned)
+            throws MQClientException {
+        for (MessageExt message : messages) {
+            MessageQueue queue =
+                    new MessageQueue(
+                            assigned.getTopic(), message.getBrokerName(), 
message.getQueueId());
+            long nextOffset = message.getQueueOffset() + 1;
+            Long current = offsetTable.get(queue);
+            // Only move forward: a seek in flight on another thread can let a 
poll hand
+            // back a message some thread already booked.
+            if (current == null || nextOffset > current) {
+                updateMessageQueueOffset(queue, nextOffset);
+            }
+        }
+    }
+
     private void emitMessages(List<MessageExt> messages, SourceContext<OUT> 
context) {
         long fetchTime = System.currentTimeMillis();
         for (MessageExt msg : messages) {
@@ -360,17 +391,32 @@ public class RocketMQSourceFunction<OUT> extends 
RichParallelSourceFunction<OUT>
      * @param messageQueues
      * @throws MQClientException
      */
+    /**
+     * The position a preceding {@code seekToBegin}/{@code seekToEnd} 
established. {@code committed}
+     * answers -1 for a group that has never committed, and storing that -1 
makes the first {@code
+     * seek} in the poll loop fail with "Seek offset illegal", after which the 
source gives up on
+     * the queue and the job finishes having read nothing.
+     */
+    private long positionAfterSeek(MessageQueue mq) throws MQClientException {
+        long offset =
+                consumer.getOffsetStore().readOffset(mq, 
ReadOffsetType.MEMORY_FIRST_THEN_STORE);
+        if (offset < 0) {
+            offset = Math.max(consumer.committed(mq), 0L);
+        }
+        return offset;
+    }
+
     private void initOffsets(List<MessageQueue> messageQueues) throws 
MQClientException {
         for (MessageQueue mq : messageQueues) {
             long offset;
             switch (startMode) {
                 case LATEST:
                     consumer.seekToEnd(mq);
-                    offset = consumer.committed(mq);
+                    offset = positionAfterSeek(mq);
                     break;
                 case EARLIEST:
                     consumer.seekToBegin(mq);
-                    offset = consumer.committed(mq);
+                    offset = positionAfterSeek(mq);
                     break;
                 case GROUP_OFFSETS:
                     offset = consumer.committed(mq);
@@ -382,7 +428,7 @@ public class RocketMQSourceFunction<OUT> extends 
RichParallelSourceFunction<OUT>
                         switch (offsetResetStrategy) {
                             case LATEST:
                                 consumer.seekToEnd(mq);
-                                offset = consumer.committed(mq);
+                                offset = positionAfterSeek(mq);
                                 log.info(
                                         "current consumer thread:{} has no 
committed offset,use Strategy:{} instead",
                                         mq,
@@ -394,7 +440,7 @@ public class RocketMQSourceFunction<OUT> extends 
RichParallelSourceFunction<OUT>
                                         mq,
                                         offsetResetStrategy);
                                 consumer.seekToBegin(mq);
-                                offset = consumer.committed(mq);
+                                offset = positionAfterSeek(mq);
                                 break;
                             default:
                                 break;
@@ -413,7 +459,7 @@ public class RocketMQSourceFunction<OUT> extends 
RichParallelSourceFunction<OUT>
                     if (specificOffset != null) {
                         offset = specificOffset;
                     } else {
-                        offset = consumer.committed(mq);
+                        offset = Math.max(consumer.committed(mq), 0L);
                     }
                     break;
                 default:
diff --git 
a/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSinkAsyncFailureTest.java
 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSinkAsyncFailureTest.java
new file mode 100644
index 0000000..8dab4cf
--- /dev/null
+++ 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/RocketMQSinkAsyncFailureTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.streaming.connectors.rocketmq;
+
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.streaming.connectors.rocketmq.common.util.TestUtils;
+
+import org.apache.rocketmq.client.producer.DefaultMQProducer;
+import org.apache.rocketmq.client.producer.SendCallback;
+import org.apache.rocketmq.common.message.Message;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.Properties;
+
+import static org.junit.Assert.fail;
+
+/**
+ * The legacy sink parks the first asynchronous send failure and rethrows it 
later. These tests pin
+ * down <em>where</em> it is rethrown: a checkpoint must not complete over 
records whose send
+ * already failed, otherwise an idle or finished stream commits the loss while 
every health signal
+ * stays green.
+ */
+public class RocketMQSinkAsyncFailureTest {
+
+    private static final FunctionSnapshotContext SNAPSHOT =
+            new FunctionSnapshotContext() {
+                @Override
+                public long getCheckpointId() {
+                    return 1L;
+                }
+
+                @Override
+                public long getCheckpointTimestamp() {
+                    return 1L;
+                }
+            };
+
+    @Test
+    public void snapshotStateSurfacesParkedAsyncSendFailureTest() throws 
Exception {
+        RocketMQSink sink = new RocketMQSink(new Properties());
+        DefaultMQProducer producer = Mockito.mock(DefaultMQProducer.class);
+        Mockito.doAnswer(
+                        invocation -> {
+                            SendCallback callback = invocation.getArgument(1);
+                            callback.onException(new IOException("CODE: 13 
message rejected"));
+                            return null;
+                        })
+                .when(producer)
+                .send(Mockito.any(Message.class), 
Mockito.any(SendCallback.class));
+        TestUtils.setFieldValue(sink, "producer", producer);
+        TestUtils.setFieldValue(sink, "async", true);
+        TestUtils.setFieldValue(
+                sink, "sinkInTps", 
Mockito.mock(org.apache.flink.metrics.Meter.class));
+
+        // The burst of records is sent before any callback fires, so invoke() 
itself
+        // returns normally and only parks the failure.
+        sink.invoke(new Message("topic", "body".getBytes()), null);
+
+        try {
+            sink.snapshotState(SNAPSHOT);
+            fail("snapshotState must fail the checkpoint when an async send 
already failed");
+        } catch (IOException expected) {
+            // the parked failure must surface here, not be committed over
+        }
+    }
+
+    @Test
+    public void snapshotStatePassesWhenNoSendFailedTest() throws Exception {
+        RocketMQSink sink = new RocketMQSink(new Properties());
+        sink.snapshotState(SNAPSHOT);
+    }
+}
diff --git 
a/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/sourceFunction/RocketMQSourceFunctionTest.java
 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/sourceFunction/RocketMQSourceFunctionTest.java
index af70aac..5b605cc 100644
--- 
a/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/sourceFunction/RocketMQSourceFunctionTest.java
+++ 
b/flink-connector-rocketmq/src/test/java/org/apache/flink/streaming/connectors/rocketmq/sourceFunction/RocketMQSourceFunctionTest.java
@@ -18,6 +18,8 @@
 
 package org.apache.flink.streaming.connectors.rocketmq.sourceFunction;
 
+import org.apache.flink.metrics.Meter;
+import org.apache.flink.streaming.api.functions.source.SourceFunction;
 import org.apache.flink.streaming.connectors.rocketmq.RocketMQConfig;
 import org.apache.flink.streaming.connectors.rocketmq.RocketMQSourceFunction;
 import 
org.apache.flink.streaming.connectors.rocketmq.common.config.OffsetResetStrategy;
@@ -26,12 +28,17 @@ import 
org.apache.flink.streaming.connectors.rocketmq.common.serialization.Simpl
 import org.apache.flink.streaming.connectors.rocketmq.common.util.TestUtils;
 
 import org.apache.rocketmq.client.consumer.DefaultLitePullConsumer;
+import org.apache.rocketmq.client.consumer.store.OffsetStore;
+import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.junit.Assert;
 import org.junit.Test;
 import org.mockito.Mockito;
 
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -74,6 +81,9 @@ public class RocketMQSourceFunctionTest {
     public void testRestartFromCheckpoint() throws Exception {
         DefaultLitePullConsumer consumer = 
Mockito.mock(DefaultLitePullConsumer.class);
         Mockito.when(consumer.committed(Mockito.any())).thenReturn(40L);
+        OffsetStore offsetStore = Mockito.mock(OffsetStore.class);
+        Mockito.when(offsetStore.readOffset(Mockito.any(), 
Mockito.any())).thenReturn(40L);
+        Mockito.when(consumer.getOffsetStore()).thenReturn(offsetStore);
         Properties properties = new Properties();
         properties.setProperty(RocketMQConfig.CONSUMER_GROUP, 
"${ConsumerGroup}");
         properties.setProperty(RocketMQConfig.CONSUMER_TOPIC, 
"${SourceTopic}");
@@ -102,4 +112,60 @@ public class RocketMQSourceFunctionTest {
         }
         assertEquals(offsetTable.containsKey(newMessageQueue), true);
     }
+
+    /**
+     * The read position must advance from the messages a poll drained, never 
from {@code
+     * committed(mq)}: with auto-commit off the broker-side commit does not 
advance, and reading the
+     * position back from it makes every poll round re-seek to the same offset 
and re-emit the whole
+     * topic (an observed 1189x duplication with a perfectly healthy, 
never-restarting job).
+     */
+    @Test
+    public void testPollAdvancesOffsetsFromConsumedMessages() throws Exception 
{
+        MessageQueue mq = new MessageQueue("tpc", "broker-0", 0);
+        DefaultLitePullConsumer consumer = 
Mockito.mock(DefaultLitePullConsumer.class);
+        // A stale broker-side commit that never advances.
+        Mockito.when(consumer.committed(Mockito.any())).thenReturn(0L);
+        Mockito.when(consumer.poll(Mockito.anyLong()))
+                .thenReturn(Collections.singletonList(message(mq, 5L)))
+                .thenReturn(Collections.emptyList());
+
+        RocketMQSourceFunction<String> source =
+                new RocketMQSourceFunction<>(
+                        new SimpleStringDeserializationSchema(), new 
Properties());
+        ConcurrentHashMap<MessageQueue, Long> offsetTable = new 
ConcurrentHashMap<>();
+        offsetTable.put(mq, 5L);
+        TestUtils.setFieldValue(source, "consumer", consumer);
+        TestUtils.setFieldValue(source, "checkPointLock", new Object());
+        TestUtils.setFieldValue(source, "enableCheckpoint", true);
+        TestUtils.setFieldValue(source, "offsetTable", offsetTable);
+        TestUtils.setFieldValue(source, "tpsMetric", 
Mockito.mock(Meter.class));
+
+        Method pollAndEmit =
+                RocketMQSourceFunction.class.getDeclaredMethod(
+                        "pollAndEmit",
+                        MessageQueue.class,
+                        SourceFunction.SourceContext.class,
+                        int.class);
+        pollAndEmit.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        SourceFunction.SourceContext<String> context =
+                Mockito.mock(SourceFunction.SourceContext.class);
+
+        pollAndEmit.invoke(source, mq, context, 32);
+        assertEquals(Long.valueOf(6L), offsetTable.get(mq));
+
+        // An empty poll must leave the position alone instead of rewinding to 
committed().
+        pollAndEmit.invoke(source, mq, context, 32);
+        assertEquals(Long.valueOf(6L), offsetTable.get(mq));
+    }
+
+    private static MessageExt message(MessageQueue mq, long queueOffset) {
+        MessageExt message = new MessageExt();
+        message.setTopic(mq.getTopic());
+        message.setBrokerName(mq.getBrokerName());
+        message.setQueueId(mq.getQueueId());
+        message.setQueueOffset(queueOffset);
+        message.setBody("payload".getBytes(StandardCharsets.UTF_8));
+        return message;
+    }
 }

Reply via email to