This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-11872-f7a575432fba5c35d36659c16e1590e83ad1f296 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 305f655b6903246ad8de9175e2b59d9ca2bc4a1b Author: Yu, Wang <[email protected]> AuthorDate: Wed Oct 28 16:39:56 2026 +0000 [Fix][Connector-V2] Restore legacy RabbitMQ checkpoint state (#11872) Co-authored-by: davidzollo <[email protected]> Co-authored-by: David Zollo <[email protected]> --- docs/en/connectors/source/Rabbitmq.md | 11 ++ docs/zh/connectors/source/Rabbitmq.md | 9 ++ .../rabbitmq/source/RabbitmqSourceState.java | 29 ++++- .../rabbitmq/source/RabbitmqSplitEnumerator.java | 3 + .../rabbitmq/RabbitmqSplitEnumeratorTest.java | 41 +++++++ .../e2e/connector/rabbitmq/RabbitmqIT.java | 121 +++++++++++++++++++++ 6 files changed, 211 insertions(+), 3 deletions(-) diff --git a/docs/en/connectors/source/Rabbitmq.md b/docs/en/connectors/source/Rabbitmq.md index f677459313..1484dc7067 100644 --- a/docs/en/connectors/source/Rabbitmq.md +++ b/docs/en/connectors/source/Rabbitmq.md @@ -368,6 +368,17 @@ RabbitMQ source supports JSON by default and Protobuf when `format` is set to `p When a SeaTunnel task fails or crashes, the RabbitMQ connection drops, and RabbitMQ automatically requeues any unacknowledged messages. Upon job restoration from a checkpoint, the reader resumes processing without message loss. +## Restoring legacy checkpoints + +Checkpoints containing the legacy `RabbitmqSourceState` serialized by SeaTunnel 2.3.13 can be read +by the current RabbitMQ source. That legacy state did not contain assigned queue splits, so restore +starts with an empty assignment map and rediscovers the queues in the current configuration. Keep +the queue configuration and broker data available during upgrade; this compatibility layer does not +reconstruct message offsets or acknowledgements that were absent from the old checkpoint. + +Current checkpoints retain assigned splits. Broker messages that were not acknowledged before a +failure can be redelivered; legacy-state compatibility does not add an exactly-once guarantee. + ## Changelog <ChangeLog /> diff --git a/docs/zh/connectors/source/Rabbitmq.md b/docs/zh/connectors/source/Rabbitmq.md index d53e2595ae..b9bbe5b87d 100644 --- a/docs/zh/connectors/source/Rabbitmq.md +++ b/docs/zh/connectors/source/Rabbitmq.md @@ -365,6 +365,15 @@ RabbitMQ Source 默认支持 JSON。当 `format` 设置为 `protobuf` 时支持 当 SeaTunnel 任务失败或异常退出时,与 RabbitMQ 的连接会断开,RabbitMQ 会自动将所有未确认(unacknowledged)的消息重新入队(requeue)。在任务从检查点恢复后,Reader 可以重新处理这些消息,避免数据丢失。 +## 恢复旧版本检查点 + +当前 RabbitMQ source 可以读取 SeaTunnel 2.3.13 序列化的旧版 `RabbitmqSourceState`。 +旧状态没有保存已分配的队列 split,因此恢复时先建立空的分配映射,再重新发现当前配置中的队列。 +升级时应保留队列配置和 broker 数据;此兼容层不会重建旧检查点中原本没有的消息位点或确认信息。 + +当前版本的检查点会保存已分配的 split。故障前尚未确认的 broker 消息可能重新投递, +旧状态兼容本身不提供额外的 Exactly-Once 保证。 + ## 变更日志 <ChangeLog /> diff --git a/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSourceState.java b/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSourceState.java index d9846611cd..8aa08ea120 100644 --- a/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSourceState.java +++ b/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSourceState.java @@ -17,7 +17,30 @@ package org.apache.seatunnel.connectors.seatunnel.rabbitmq.source; -import java.io.Serializable; +import org.apache.seatunnel.connectors.seatunnel.rabbitmq.split.RabbitmqSplitEnumeratorState; -/** State for RabbitMQ source reader. */ -public class RabbitmqSourceState implements Serializable {} +import java.util.Collections; + +/** + * Legacy checkpoint state created by RabbitMQ sources before the split enumerator state fix. + * + * @deprecated only used to restore checkpoints written by older connector versions + */ +@Deprecated +public class RabbitmqSourceState extends RabbitmqSplitEnumeratorState { + private static final long serialVersionUID = -1143819030309308746L; + + public RabbitmqSourceState() { + super(Collections.emptyMap()); + } + + /** + * Replaces a legacy instance whose serialized stream has no current superclass state. + * + * <p>Serializable constructors do not initialize those absent fields. A new replacement + * guarantees an empty assignment map and keeps the legacy marker used for restore logging. + */ + private Object readResolve() { + return new RabbitmqSourceState(); + } +} diff --git a/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSplitEnumerator.java b/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSplitEnumerator.java index 2523c40577..b06e7968f0 100644 --- a/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSplitEnumerator.java +++ b/seatunnel-connectors-v2/connector-rabbitmq/src/main/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/source/RabbitmqSplitEnumerator.java @@ -84,6 +84,9 @@ public class RabbitmqSplitEnumerator RabbitmqSplitEnumeratorState checkpointState) { this(context, rabbitmqConfig, queues); + if (checkpointState instanceof RabbitmqSourceState) { + log.info("Restoring RabbitMQ source from legacy checkpoint state"); + } if (checkpointState != null && checkpointState.getAssignedSplits() != null) { this.assignedSplits.putAll(checkpointState.getAssignedSplits()); for (String assignedSplitId : checkpointState.getAssignedSplits().keySet()) { diff --git a/seatunnel-connectors-v2/connector-rabbitmq/src/test/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/RabbitmqSplitEnumeratorTest.java b/seatunnel-connectors-v2/connector-rabbitmq/src/test/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/RabbitmqSplitEnumeratorTest.java index 22b3d2f045..f454ae398b 100644 --- a/seatunnel-connectors-v2/connector-rabbitmq/src/test/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/RabbitmqSplitEnumeratorTest.java +++ b/seatunnel-connectors-v2/connector-rabbitmq/src/test/java/org/apache/seatunnel/connectors/seatunnel/rabbitmq/RabbitmqSplitEnumeratorTest.java @@ -20,6 +20,7 @@ package org.apache.seatunnel.connectors.seatunnel.rabbitmq; import org.apache.seatunnel.api.source.SourceSplitEnumerator; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.config.RabbitmqConfig; +import org.apache.seatunnel.connectors.seatunnel.rabbitmq.source.RabbitmqSourceState; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.source.RabbitmqSplitEnumerator; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.split.RabbitmqSplit; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.split.RabbitmqSplitEnumeratorState; @@ -28,7 +29,11 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import java.io.ByteArrayInputStream; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -40,6 +45,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class RabbitmqSplitEnumeratorTest { + private static final long LEGACY_STATE_SERIAL_VERSION_UID = -1143819030309308746L; + + // Serialized by RabbitmqSourceState from SeaTunnel 2.3.13. + private static final String LEGACY_CHECKPOINT_STATE = + "rO0ABXNyAE1vcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5yYWJiaXRtcS5zb3VyY2UuUmFiYml0bXFTb3VyY2VTdGF0ZfAgVqbzFPa2AgAAeHA="; + /** * Tests the split assignment logic of the {@link RabbitmqSplitEnumerator} in a Multi-Table * scenario. @@ -157,4 +168,34 @@ public class RabbitmqSplitEnumeratorTest { Assertions.assertTrue(newState.getAssignedSplits().containsKey("queue_A")); Assertions.assertTrue(newState.getAssignedSplits().containsKey("queue_B")); } + + @Test + public void testRestoreLegacySourceState() throws Exception { + Assertions.assertEquals( + LEGACY_STATE_SERIAL_VERSION_UID, + ObjectStreamClass.lookup(RabbitmqSourceState.class).getSerialVersionUID()); + + RabbitmqSplitEnumeratorState legacyState; + try (ObjectInputStream input = + new ObjectInputStream( + new ByteArrayInputStream( + Base64.getDecoder().decode(LEGACY_CHECKPOINT_STATE)))) { + Object state = input.readObject(); + Assertions.assertInstanceOf(RabbitmqSourceState.class, state); + legacyState = Assertions.assertInstanceOf(RabbitmqSplitEnumeratorState.class, state); + } + Assertions.assertNotNull(legacyState.getAssignedSplits()); + Assertions.assertTrue(legacyState.getAssignedSplits().isEmpty()); + + @SuppressWarnings("unchecked") + SourceSplitEnumerator.Context<RabbitmqSplit> context = + mock(SourceSplitEnumerator.Context.class); + List<String> queues = Arrays.asList("queue_A", "queue_B"); + + RabbitmqSplitEnumerator enumerator = + new RabbitmqSplitEnumerator( + context, mock(RabbitmqConfig.class), queues, legacyState); + + Assertions.assertEquals(queues.size(), enumerator.currentUnassignedSplitSize()); + } } diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-rabbitmq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/rabbitmq/RabbitmqIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-rabbitmq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/rabbitmq/RabbitmqIT.java index 2c3ea073ef..1deb77ab43 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-rabbitmq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/rabbitmq/RabbitmqIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-rabbitmq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/rabbitmq/RabbitmqIT.java @@ -21,6 +21,10 @@ import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory; import org.apache.seatunnel.shade.com.typesafe.config.ConfigRenderOptions; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.catalog.PhysicalColumn; import org.apache.seatunnel.api.table.catalog.TableIdentifier; @@ -41,6 +45,9 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.client.RabbitmqClient; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.config.RabbitmqConfig; import org.apache.seatunnel.connectors.seatunnel.rabbitmq.source.DeliveryMessage; +import org.apache.seatunnel.connectors.seatunnel.rabbitmq.source.RabbitmqSource; +import org.apache.seatunnel.connectors.seatunnel.rabbitmq.split.RabbitmqSplit; +import org.apache.seatunnel.connectors.seatunnel.rabbitmq.split.RabbitmqSplitEnumeratorState; import org.apache.seatunnel.core.starter.seatunnel.args.ClientCommandArgs; import org.apache.seatunnel.core.starter.seatunnel.command.SeaTunnelConfValidateCommand; import org.apache.seatunnel.core.starter.utils.CommandLineUtils; @@ -58,6 +65,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.output.Slf4jLogConsumer; @@ -67,6 +75,7 @@ import org.testcontainers.shaded.org.apache.commons.lang3.tuple.Pair; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerLoggerFactory; +import com.rabbitmq.client.AMQP; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; @@ -84,11 +93,13 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -150,6 +161,116 @@ public class RabbitmqIT extends TestSuiteBase implements TestResource { } } + /** + * Exercises the released checkpoint bytes through the production serializer and restore hook. + * + * <p>The restored queue is assigned once, consumed from the real broker, and checkpoint-acked; + * the resulting current state must not rediscover the already assigned split on restore. + */ + @Test + public void testLegacyCheckpointRestoreConsumesReassignedQueue() throws Exception { + // Captured from RabbitmqSourceState in SeaTunnel 2.3.13, before assigned-split state + // existed. + byte[] legacy = + Base64.getDecoder() + .decode( + "rO0ABXNyAE1vcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5yYWJiaXRtcS5zb3VyY2UuUmFiYml0bXFTb3VyY2VTdGF0ZfAgVqbzFPa2AgAAeHA="); + String queue = "legacy-restore-" + UUID.randomUUID(); + try (Connection connection = dryRunConnection(); + Channel channel = connection.createChannel()) { + channel.queueDeclare(queue, DURABLE, EXCLUSIVE, AUTO_DELETE, null); + try { + channel.confirmSelect(); + channel.basicPublish( + "", + queue, + new AMQP.BasicProperties.Builder().correlationId(queue).build(), + "{\"name\":\"legacy-restored\"}".getBytes(StandardCharsets.UTF_8)); + channel.waitForConfirmsOrDie(5000); + RabbitmqSource source = + new RabbitmqSource(ReadonlyConfig.fromMap(dryRunOptions(queue))); + RabbitmqSplitEnumeratorState oldState = + source.getEnumeratorStateSerializer().deserialize(legacy); + Assertions.assertNotNull(oldState.getAssignedSplits()); + Assertions.assertTrue(oldState.getAssignedSplits().isEmpty()); + @SuppressWarnings("unchecked") + SourceSplitEnumerator.Context<RabbitmqSplit> context = + Mockito.mock(SourceSplitEnumerator.Context.class); + Mockito.when(context.registeredReaders()).thenReturn(Collections.singleton(0)); + List<RabbitmqSplit> assigned = new ArrayList<>(); + Mockito.doAnswer( + invocation -> { + assigned.add(invocation.getArgument(1)); + return null; + }) + .when(context) + .assignSplit(Mockito.eq(0), Mockito.any(RabbitmqSplit.class)); + try (SourceSplitEnumerator<RabbitmqSplit, RabbitmqSplitEnumeratorState> enumerator = + source.restoreEnumerator(context, oldState)) { + enumerator.open(); + enumerator.run(); + enumerator.registerReader(0); + Assertions.assertEquals(1, assigned.size()); + Assertions.assertEquals(queue, assigned.get(0).splitId()); + Assertions.assertEquals(0, enumerator.currentUnassignedSplitSize()); + List<SeaTunnelRow> rows = new ArrayList<>(); + Collector<SeaTunnelRow> collector = + new Collector<SeaTunnelRow>() { + @Override + public void collect(SeaTunnelRow row) { + rows.add(row); + } + + @Override + public Object getCheckpointLock() { + return rows; + } + }; + SourceReader.Context readerContext = Mockito.mock(SourceReader.Context.class); + Mockito.when(readerContext.getBoundedness()).thenReturn(Boundedness.UNBOUNDED); + try (SourceReader<SeaTunnelRow, RabbitmqSplit> reader = + source.createReader(readerContext)) { + reader.open(); + reader.addSplits(assigned); + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + reader.pollNext(collector); + Assertions.assertEquals(1, rows.size()); + Assertions.assertEquals( + "legacy-restored", rows.get(0).getField(0)); + }); + reader.snapshotState(1L); + reader.notifyCheckpointComplete(1L); + } + Assertions.assertNull( + channel.basicGet(queue, true), + "checkpoint-acked data must not be redelivered after reader close"); + RabbitmqSplitEnumeratorState current = + source.getEnumeratorStateSerializer() + .deserialize( + source.getEnumeratorStateSerializer() + .serialize(enumerator.snapshotState(1L))); + Assertions.assertEquals( + Collections.singleton(queue), current.getAssignedSplits().keySet()); + assigned.clear(); + try (SourceSplitEnumerator<RabbitmqSplit, RabbitmqSplitEnumeratorState> + restored = source.restoreEnumerator(context, current)) { + restored.open(); + restored.run(); + Assertions.assertEquals(0, restored.currentUnassignedSplitSize()); + Assertions.assertTrue( + assigned.isEmpty(), + "current assigned state must not assign the queue twice"); + } + } + } finally { + channel.queueDelete(queue); + } + } + } + @Test public void testDryRunLeavesMessagesAndConsumersUnchanged() throws Exception { try (Connection connection = dryRunConnection();
