This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 244455533b [Improve][Connector-V2] Harden Google Bigtable source-side
state synchronization (#11144)
244455533b is described below
commit 244455533b2a25be916bc8a9ad5e3557e6668de4
Author: 雷炯 <[email protected]>
AuthorDate: Tue Jul 21 22:41:17 2026 +0800
[Improve][Connector-V2] Harden Google Bigtable source-side state
synchronization (#11144)
Co-authored-by: Cursor <[email protected]>
---
.../bigtable/source/BigtableSourceReader.java | 45 +-
.../source/BigtableSourceSplitEnumerator.java | 52 ++-
.../bigtable/source/BigtableSourceState.java | 25 +-
.../source/BigtableSourceSplitEnumeratorTest.java | 152 +++++++
.../source/BigtableSourceStateRecoveryTest.java | 458 +++++++++++++++++++++
5 files changed, 701 insertions(+), 31 deletions(-)
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReader.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReader.java
index 3dcfa310b2..fecec582d3 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReader.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReader.java
@@ -64,6 +64,13 @@ public class BigtableSourceReader implements
SourceReader<SeaTunnelRow, Bigtable
/** The split currently being read. Persisted in checkpoint to survive
failover. */
private volatile BigtableSourceSplit currentSplit = null;
+ /**
+ * Guards compound reader-state transitions so a concurrent {@link
#snapshotState(long)} never
+ * observes a half-updated view of {@code pendingSplits} and {@code
currentSplit}. The actual
+ * {@code readRows} network scan is intentionally kept outside this lock.
+ */
+ private final Object stateLock = new Object();
+
/**
* Set of field names that map to the Bigtable row key. Populated from
{@code rowkey_column}
* config; falls back to the literal field name {@value ROW_KEY_FIELD}
when not configured.
@@ -109,13 +116,23 @@ public class BigtableSourceReader implements
SourceReader<SeaTunnelRow, Bigtable
@Override
public void pollNext(Collector<SeaTunnelRow> output) throws Exception {
- final BigtableSourceSplit split = pendingSplits.poll();
+ final BigtableSourceSplit split;
+ synchronized (stateLock) {
+ // Poll and assign currentSplit atomically so a concurrent
snapshotState() always sees a
+ // consistent (pendingSplits, currentSplit) pair, and the
in-flight split can be
+ // re-enqueued on restore.
+ split = pendingSplits.poll();
+ if (Objects.nonNull(split)) {
+ currentSplit = split;
+ }
+ }
if (Objects.nonNull(split)) {
- // Assign currentSplit before reading so checkpoints taken during
readSplit()
- // include it and can re-enqueue it on restore.
- currentSplit = split;
+ // The network scan runs outside stateLock; only output.collect()
takes the checkpoint
+ // lock inside readSplit().
readSplit(split, output);
- currentSplit = null;
+ synchronized (stateLock) {
+ currentSplit = null;
+ }
} else if (noMoreSplits && pendingSplits.isEmpty()) {
log.info("Closed the bounded Bigtable source");
context.signalNoMoreElement();
@@ -234,18 +251,22 @@ public class BigtableSourceReader implements
SourceReader<SeaTunnelRow, Bigtable
@Override
public List<BigtableSourceSplit> snapshotState(long checkpointId) {
- List<BigtableSourceSplit> state = new ArrayList<>();
- // Include the split currently being read so it can be re-enqueued on
restore.
- if (currentSplit != null) {
- state.add(currentSplit);
+ synchronized (stateLock) {
+ List<BigtableSourceSplit> state = new ArrayList<>();
+ // Include the split currently being read so it can be re-enqueued
on restore.
+ if (currentSplit != null) {
+ state.add(currentSplit);
+ }
+ state.addAll(pendingSplits);
+ return state;
}
- state.addAll(pendingSplits);
- return state;
}
@Override
public void addSplits(List<BigtableSourceSplit> splits) {
- pendingSplits.addAll(splits);
+ synchronized (stateLock) {
+ pendingSplits.addAll(splits);
+ }
}
@Override
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumerator.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumerator.java
index 3d41a2ad39..df72fda91d 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumerator.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumerator.java
@@ -47,31 +47,43 @@ public class BigtableSourceSplitEnumerator
private Set<BigtableSourceSplit> pendingSplits;
private boolean initialized = false;
+ /**
+ * Guards the shared assignment state ({@code assignedSplits}, {@code
pendingSplits}, {@code
+ * initialized}) against concurrent enumerator callbacks. {@link
#initializePendingSplits()} and
+ * {@link #assignSplit(int)} must only run while this lock is held.
+ */
+ private final Object stateLock = new Object();
+
public BigtableSourceSplitEnumerator(
Context<BigtableSourceSplit> context, BigtableParameters
parameters) {
- this(context, parameters, new HashSet<>());
+ this(context, parameters, null);
}
public BigtableSourceSplitEnumerator(
Context<BigtableSourceSplit> context,
BigtableParameters parameters,
BigtableSourceState sourceState) {
- this(context, parameters, sourceState.getAssignedSplits());
- }
-
- private BigtableSourceSplitEnumerator(
- Context<BigtableSourceSplit> context,
- BigtableParameters parameters,
- Set<BigtableSourceSplit> assignedSplits) {
this.context = context;
this.parameters = parameters;
- this.assignedSplits = new HashSet<>(assignedSplits);
+ if (sourceState == null) {
+ this.assignedSplits = new HashSet<>();
+ this.pendingSplits = new HashSet<>();
+ this.initialized = false;
+ } else {
+ this.assignedSplits = new
HashSet<>(sourceState.getAssignedSplits());
+ // Restore pending splits first so a
returned-but-not-yet-reassigned split survives
+ // recovery even when its ID is already present in assignedSplits.
+ this.pendingSplits = new HashSet<>(sourceState.getPendingSplits());
+ // Only skip table-split discovery when the checkpoint captured
real enumerator
+ // progress.
+ // An empty-empty checkpoint (before any reader registered) must
still discover splits.
+ this.initialized = !this.assignedSplits.isEmpty() ||
!this.pendingSplits.isEmpty();
+ }
}
@Override
public void open() {
- this.pendingSplits = new HashSet<>();
- this.initialized = false;
+ // State is fully initialized in the constructor; nothing to reset on
open().
}
@Override
@@ -87,9 +99,11 @@ public class BigtableSourceSplitEnumerator
@Override
public void addSplitsBack(List<BigtableSourceSplit> splits, int subtaskId)
{
if (!splits.isEmpty()) {
- pendingSplits.addAll(splits);
- if (context.registeredReaders().contains(subtaskId)) {
- assignSplit(subtaskId);
+ synchronized (stateLock) {
+ pendingSplits.addAll(splits);
+ if (context.registeredReaders().contains(subtaskId)) {
+ assignSplit(subtaskId);
+ }
}
}
}
@@ -101,13 +115,17 @@ public class BigtableSourceSplitEnumerator
@Override
public void registerReader(int subtaskId) {
- initializePendingSplits();
- assignSplit(subtaskId);
+ synchronized (stateLock) {
+ initializePendingSplits();
+ assignSplit(subtaskId);
+ }
}
@Override
public BigtableSourceState snapshotState(long checkpointId) throws
Exception {
- return new BigtableSourceState(assignedSplits);
+ synchronized (stateLock) {
+ return new BigtableSourceState(assignedSplits, pendingSplits);
+ }
}
@Override
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceState.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceState.java
index e44fd024a9..932a54503c 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceState.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceState.java
@@ -18,6 +18,8 @@
package org.apache.seatunnel.connectors.seatunnel.bigtable.source;
import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.Set;
public class BigtableSourceState implements Serializable {
@@ -25,12 +27,31 @@ public class BigtableSourceState implements Serializable {
private static final long serialVersionUID = 1L;
private final Set<BigtableSourceSplit> assignedSplits;
+ /**
+ * Splits returned via {@code addSplitsBack()} but not yet reassigned.
Added in #11144; absent
+ * (null) when an older checkpoint payload is deserialized with the same
{@link
+ * #serialVersionUID}.
+ */
+ private final Set<BigtableSourceSplit> pendingSplits;
- public BigtableSourceState(Set<BigtableSourceSplit> assignedSplits) {
- this.assignedSplits = assignedSplits;
+ public BigtableSourceState(
+ Set<BigtableSourceSplit> assignedSplits, Set<BigtableSourceSplit>
pendingSplits) {
+ // Defensive copies: never alias the enumerator's live mutable sets
into checkpoint state,
+ // otherwise later mutations could corrupt an already-snapshotted
state object.
+ this.assignedSplits = copyOrEmpty(assignedSplits);
+ this.pendingSplits = copyOrEmpty(pendingSplits);
}
public Set<BigtableSourceSplit> getAssignedSplits() {
return assignedSplits;
}
+
+ public Set<BigtableSourceSplit> getPendingSplits() {
+ // Null only when Java deserializes a pre-#11144 checkpoint that
lacked this field.
+ return pendingSplits == null ? Collections.emptySet() : pendingSplits;
+ }
+
+ private static Set<BigtableSourceSplit>
copyOrEmpty(Set<BigtableSourceSplit> splits) {
+ return splits == null ? new HashSet<>() : new HashSet<>(splits);
+ }
}
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumeratorTest.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumeratorTest.java
index b9d764b34a..b0767c7252 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumeratorTest.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitEnumeratorTest.java
@@ -17,9 +17,24 @@
package org.apache.seatunnel.connectors.seatunnel.bigtable.source;
+import org.apache.seatunnel.api.common.metrics.MetricsContext;
+import org.apache.seatunnel.api.event.EventListener;
+import org.apache.seatunnel.api.source.SourceEvent;
+import org.apache.seatunnel.api.source.SourceSplitEnumerator;
+import
org.apache.seatunnel.connectors.seatunnel.bigtable.config.BigtableParameters;
+
import org.junit.jupiter.api.Test;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class BigtableSourceSplitEnumeratorTest {
@@ -37,4 +52,141 @@ public class BigtableSourceSplitEnumeratorTest {
assertEquals("aaa", split.getStartRowKey());
assertEquals("zzz", split.getEndRowKey());
}
+
+ /**
+ * Regression for the returned-split recovery window: a split requeued via
{@link
+ * BigtableSourceSplitEnumerator#addSplitsBack} but not yet reassigned
must survive enumerator
+ * checkpoint / restore and be reassigned to the reader.
+ */
+ @Test
+ void testReturnedSplitSurvivesCheckpointRestoreAndReassign() throws
Exception {
+ TestingContext context = new TestingContext(1);
+ BigtableParameters parameters =
+ BigtableParameters.builder()
+ .projectId("test-project")
+ .instanceId("test-instance")
+ .table("test-table")
+ .build();
+
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(context, parameters);
+ enumerator.open();
+
+ context.registerReaderForTest(0);
+ enumerator.registerReader(0);
+ BigtableSourceSplit split = context.getLastAssignedSplit(0);
+ assertEquals("bigtable_source_split_0", split.splitId());
+
+ // Simulate failover: reader returns its split before the enumerator
can reassign it.
+ context.unregisterReaderForTest(0);
+ enumerator.addSplitsBack(Collections.singletonList(split), 0);
+
+ BigtableSourceState checkpoint = enumerator.snapshotState(1L);
+ assertTrue(checkpoint.getPendingSplits().contains(split));
+ assertTrue(checkpoint.getAssignedSplits().contains(split));
+
+ BigtableSourceSplitEnumerator restored =
+ new BigtableSourceSplitEnumerator(context, parameters,
checkpoint);
+ restored.open();
+
+ context.clearAssignments();
+ context.registerReaderForTest(0);
+ restored.registerReader(0);
+
+ assertEquals(1, context.getAssignedSplitCount(0));
+ assertEquals(split.splitId(),
context.getLastAssignedSplit(0).splitId());
+ assertEquals(0, restored.currentUnassignedSplitSize());
+ }
+
+ @Test
+ void testSnapshotStateDefensivelyCopiesPendingSplits() throws Exception {
+ TestingContext context = new TestingContext(1);
+ BigtableParameters parameters =
+ BigtableParameters.builder()
+ .projectId("test-project")
+ .instanceId("test-instance")
+ .table("test-table")
+ .build();
+
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(context, parameters);
+ enumerator.open();
+
+ context.registerReaderForTest(0);
+ enumerator.registerReader(0);
+ BigtableSourceSplit split = context.getLastAssignedSplit(0);
+
+ context.unregisterReaderForTest(0);
+ enumerator.addSplitsBack(Collections.singletonList(split), 0);
+
+ BigtableSourceState checkpoint = enumerator.snapshotState(1L);
+ assertEquals(1, checkpoint.getPendingSplits().size());
+ checkpoint.getPendingSplits().clear();
+
+ assertEquals(1, enumerator.currentUnassignedSplitSize());
+ }
+
+ private static class TestingContext
+ implements SourceSplitEnumerator.Context<BigtableSourceSplit> {
+
+ private final int parallelism;
+ private final Set<Integer> registeredReaders = new HashSet<>();
+ private final Map<Integer, List<BigtableSourceSplit>> assignments =
new HashMap<>();
+
+ private TestingContext(int parallelism) {
+ this.parallelism = parallelism;
+ }
+
+ void registerReaderForTest(int subtaskId) {
+ registeredReaders.add(subtaskId);
+ }
+
+ void unregisterReaderForTest(int subtaskId) {
+ registeredReaders.remove(subtaskId);
+ }
+
+ void clearAssignments() {
+ assignments.clear();
+ }
+
+ int getAssignedSplitCount(int subtaskId) {
+ return assignments.getOrDefault(subtaskId,
Collections.emptyList()).size();
+ }
+
+ BigtableSourceSplit getLastAssignedSplit(int subtaskId) {
+ List<BigtableSourceSplit> splits = assignments.get(subtaskId);
+ return splits.get(splits.size() - 1);
+ }
+
+ @Override
+ public int currentParallelism() {
+ return parallelism;
+ }
+
+ @Override
+ public Set<Integer> registeredReaders() {
+ return registeredReaders;
+ }
+
+ @Override
+ public void assignSplit(int subtaskId, List<BigtableSourceSplit>
splits) {
+ assignments.computeIfAbsent(subtaskId, ignored -> new
ArrayList<>()).addAll(splits);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {}
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event)
{}
+
+ @Override
+ public MetricsContext getMetricsContext() {
+ return null;
+ }
+
+ @Override
+ public EventListener getEventListener() {
+ return null;
+ }
+ }
}
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceStateRecoveryTest.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceStateRecoveryTest.java
new file mode 100644
index 0000000000..e67b0b4ec5
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceStateRecoveryTest.java
@@ -0,0 +1,458 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.bigtable.source;
+
+import org.apache.seatunnel.api.common.metrics.MetricsContext;
+import org.apache.seatunnel.api.event.EventListener;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.source.SourceEvent;
+import org.apache.seatunnel.api.source.SourceReader;
+import org.apache.seatunnel.api.source.SourceSplitEnumerator;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import
org.apache.seatunnel.connectors.seatunnel.bigtable.client.BigtableClient;
+import
org.apache.seatunnel.connectors.seatunnel.bigtable.config.BigtableParameters;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import com.google.api.gax.rpc.ServerStream;
+import com.google.cloud.bigtable.data.v2.BigtableDataClient;
+import com.google.cloud.bigtable.data.v2.models.Query;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+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.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Deep regression tests for Bigtable source checkpoint / recovery semantics
introduced in #11144.
+ */
+class BigtableSourceStateRecoveryTest {
+
+ /**
+ * Bytes from the pre-#11144 {@code BigtableSourceState} (assignedSplits
only, same
+ * serialVersionUID) for split {@code bigtable_source_split_0} with row
range [start, end).
+ */
+ private static final String LEGACY_ASSIGNED_ONLY_STATE_BASE64 =
+
"rO0ABXNyAE1vcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5iaWd0YWJsZS5zb3VyY2UuQmlndGFibGVTb3VyY2VTdGF0ZQAAAAAAAAABAgABTAAOYXNzaWduZWRTcGxpdHN0AA9MamF2YS91dGlsL1NldDt4cHNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAABA/QAAAAAAAAXNyAE1vcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5iaWd0YWJsZS5zb3VyY2UuQmlndGFibGVTb3VyY2VTcGxpdAAAAAAAAAABAgADTAAJZW5kUm93S2V5dAASTGphdmEvbGFuZy9TdHJpbmc7TAAHc3BsaXRJZHEAfgAGTAALc3RhcnRSb3dLZXlxAH4ABnhwdAADZW5kdAAXYmlndGFibGV
[...]
+
+ private static final BigtableParameters PARAMETERS =
+ BigtableParameters.builder()
+ .projectId("test-project")
+ .instanceId("test-instance")
+ .table("test-table")
+ .build();
+
+ /**
+ * If the enumerator checkpoints before any reader registers, both sets
are empty. Restore must
+ * still discover and assign the table split on the next {@link
+ * BigtableSourceSplitEnumerator#registerReader(int)}.
+ */
+ @Test
+ void testEmptyEnumeratorCheckpointStillDiscoversSplitsOnRestore() throws
Exception {
+ TestingContext context = new TestingContext(1);
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS);
+ enumerator.open();
+
+ BigtableSourceState emptyCheckpoint = enumerator.snapshotState(1L);
+ assertTrue(emptyCheckpoint.getAssignedSplits().isEmpty());
+ assertTrue(emptyCheckpoint.getPendingSplits().isEmpty());
+
+ BigtableSourceSplitEnumerator restored =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS,
emptyCheckpoint);
+ restored.open();
+
+ context.registerReaderForTest(0);
+ restored.registerReader(0);
+
+ assertEquals(1, context.getAssignedSplitCount(0));
+ assertEquals("bigtable_source_split_0",
context.getLastAssignedSplit(0).splitId());
+ }
+
+ /**
+ * Documents Daniel's blocker: without pendingSplits in checkpoint state,
a returned split whose
+ * ID is already in assignedSplits is dropped after restore.
+ */
+ @Test
+ void testReturnedSplitDroppedWhenCheckpointOmitsPending() throws Exception
{
+ TestingContext context = new TestingContext(1);
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS);
+ enumerator.open();
+
+ context.registerReaderForTest(0);
+ enumerator.registerReader(0);
+ BigtableSourceSplit split = context.getLastAssignedSplit(0);
+
+ context.unregisterReaderForTest(0);
+ enumerator.addSplitsBack(Collections.singletonList(split), 0);
+ assertEquals(1, enumerator.currentUnassignedSplitSize());
+
+ // Simulate pre-fix snapshot: only assignedSplits, pending lost.
+ BigtableSourceState buggyCheckpoint =
+ new BigtableSourceState(
+ new HashSet<>(Collections.singleton(split)),
Collections.emptySet());
+
+ TestingContext restoreContext = new TestingContext(1);
+ BigtableSourceSplitEnumerator restored =
+ new BigtableSourceSplitEnumerator(restoreContext, PARAMETERS,
buggyCheckpoint);
+ restored.open();
+
+ restoreContext.registerReaderForTest(0);
+ restored.registerReader(0);
+
+ assertEquals(
+ 0,
+ restoreContext.getAssignedSplitCount(0),
+ "Without pendingSplits in checkpoint, returned split is
dropped on restore");
+ }
+
+ /**
+ * Checkpoints written before #11144 only serialized assignedSplits. After
Java deserialization
+ * into the new class shape, pendingSplits is null; restore must not fail.
+ */
+ @Test
+ void testLegacyCheckpointWithNullPendingSplitsRestoresEnumerator() throws
Exception {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "start", "end");
+ BigtableSourceState legacyState =
+ new BigtableSourceState(
+ new HashSet<>(Collections.singleton(split)),
Collections.emptySet());
+ java.lang.reflect.Field pendingField =
+ BigtableSourceState.class.getDeclaredField("pendingSplits");
+ pendingField.setAccessible(true);
+ pendingField.set(legacyState, null);
+
+ assertTrue(legacyState.getPendingSplits().isEmpty());
+
+ TestingContext context = new TestingContext(1);
+ BigtableSourceSplitEnumerator restored =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS,
legacyState);
+ restored.open();
+
+ context.registerReaderForTest(0);
+ restored.registerReader(0);
+
+ assertEquals(0, restored.currentUnassignedSplitSize());
+ }
+
+ /**
+ * Round-trip bytes produced by the pre-#11144 single-field {@code
BigtableSourceState} shape
+ * (same {@code serialVersionUID}) must deserialize into the new class
without restore failure.
+ */
+ @Test
+ void testDeserializePrePatchBigtableSourceStateBytes() throws Exception {
+ byte[] legacyBytes =
Base64.getDecoder().decode(LEGACY_ASSIGNED_ONLY_STATE_BASE64);
+
+ BigtableSourceState deserialized;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(legacyBytes))) {
+ deserialized = (BigtableSourceState) ois.readObject();
+ }
+
+ assertEquals(1, deserialized.getAssignedSplits().size());
+ assertTrue(deserialized.getPendingSplits().isEmpty());
+
+ TestingContext context = new TestingContext(1);
+ BigtableSourceSplitEnumerator restored =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS,
deserialized);
+ restored.open();
+
+ context.registerReaderForTest(0);
+ restored.registerReader(0);
+
+ assertEquals(0, restored.currentUnassignedSplitSize());
+ }
+
+ /**
+ * End-to-end reader + enumerator failover: in-flight reader split flows
back via addSplitsBack.
+ */
+ @Test
+ void testReaderEnumeratorFailoverHandoff() throws Exception {
+ TestingContext enumContext = new TestingContext(1);
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(enumContext, PARAMETERS);
+ enumerator.open();
+ enumContext.registerReaderForTest(0);
+ enumerator.registerReader(0);
+ BigtableSourceSplit enumeratorSplit =
enumContext.getLastAssignedSplit(0);
+
+ BigtableSourceReader reader = createReaderWithMockedEmptyStream();
+ reader.addSplits(Collections.singletonList(enumeratorSplit));
+ reader.handleNoMoreSplits();
+
+ final AtomicReference<List<BigtableSourceSplit>> readerCheckpoint =
new AtomicReference<>();
+ mockEmptyReadStream(reader, () ->
readerCheckpoint.set(reader.snapshotState(1L)));
+
+ Collector<SeaTunnelRow> collector = mockCollector();
+ reader.pollNext(collector);
+
+ List<BigtableSourceSplit> returnedSplits = readerCheckpoint.get();
+ assertNotNull(returnedSplits);
+ assertEquals(1, returnedSplits.size());
+
+ enumContext.unregisterReaderForTest(0);
+ enumerator.addSplitsBack(returnedSplits, 0);
+
+ BigtableSourceState enumCheckpoint = enumerator.snapshotState(2L);
+
assertTrue(enumCheckpoint.getPendingSplits().contains(enumeratorSplit));
+
+ TestingContext restoreContext = new TestingContext(1);
+ BigtableSourceSplitEnumerator restoredEnum =
+ new BigtableSourceSplitEnumerator(restoreContext, PARAMETERS,
enumCheckpoint);
+ restoredEnum.open();
+ restoreContext.registerReaderForTest(0);
+ restoredEnum.registerReader(0);
+
+ assertEquals(1, restoreContext.getAssignedSplitCount(0));
+ assertEquals(enumeratorSplit.splitId(),
restoreContext.getLastAssignedSplit(0).splitId());
+ }
+
+ /** Concurrent reader snapshot and addSplits must remain thread-safe (no
torn exceptions). */
+ @Test
+ void testConcurrentReaderSnapshotAndAddSplits() throws Exception {
+ BigtableSourceReader reader = createReaderWithMockedEmptyStream();
+ BigtableSourceSplit splitA = new BigtableSourceSplit(0, "a", "m");
+ BigtableSourceSplit splitB = new BigtableSourceSplit(1, "m", "z");
+
+ ExecutorService pool = Executors.newFixedThreadPool(4);
+ CountDownLatch start = new CountDownLatch(1);
+ List<List<BigtableSourceSplit>> snapshots =
Collections.synchronizedList(new ArrayList<>());
+
+ for (int i = 0; i < 100; i++) {
+ pool.submit(
+ () -> {
+ try {
+ start.await();
+ snapshots.add(reader.snapshotState(1L));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ pool.submit(
+ () -> {
+ try {
+ start.await();
+
reader.addSplits(Collections.singletonList(splitA));
+
reader.addSplits(Collections.singletonList(splitB));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ start.countDown();
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
+ assertEquals(100, snapshots.size());
+ for (List<BigtableSourceSplit> snapshot : snapshots) {
+ assertNotNull(snapshot);
+ }
+ }
+
+ @Test
+ void testBigtableSourceStateJavaSerializationRoundTrip() throws Exception {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "start", "end");
+ BigtableSourceState original =
+ new BigtableSourceState(
+ new HashSet<>(Collections.singleton(split)),
+ new HashSet<>(Collections.singleton(split)));
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+ oos.writeObject(original);
+ }
+
+ BigtableSourceState restored;
+ try (ObjectInputStream ois =
+ new ObjectInputStream(new
ByteArrayInputStream(bos.toByteArray()))) {
+ restored = (BigtableSourceState) ois.readObject();
+ }
+
+ assertEquals(1, restored.getAssignedSplits().size());
+ assertEquals(1, restored.getPendingSplits().size());
+ assertEquals(
+ "bigtable_source_split_0",
+ restored.getAssignedSplits().iterator().next().splitId());
+ restored.getAssignedSplits().clear();
+ assertFalse(
+ original.getAssignedSplits().isEmpty(),
+ "Defensive copy must isolate mutations on assignedSplits");
+ }
+
+ @Test
+ void testAddSplitsBackWithRegisteredReaderImmediatelyReassigns() throws
Exception {
+ TestingContext context = new TestingContext(1);
+ BigtableSourceSplitEnumerator enumerator =
+ new BigtableSourceSplitEnumerator(context, PARAMETERS);
+ enumerator.open();
+
+ context.registerReaderForTest(0);
+ enumerator.registerReader(0);
+ BigtableSourceSplit split = context.getLastAssignedSplit(0);
+
+ context.clearAssignments();
+ enumerator.addSplitsBack(Collections.singletonList(split), 0);
+
+ assertEquals(0, enumerator.currentUnassignedSplitSize());
+ assertEquals(1, context.getAssignedSplitCount(0));
+
+ BigtableSourceState checkpoint = enumerator.snapshotState(1L);
+ assertTrue(checkpoint.getPendingSplits().isEmpty());
+ assertTrue(checkpoint.getAssignedSplits().contains(split));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static BigtableSourceReader createReaderWithMockedEmptyStream()
throws Exception {
+ BigtableClient mockClient = mock(BigtableClient.class);
+ BigtableDataClient mockDataClient = mock(BigtableDataClient.class);
+ when(mockClient.getDataClient()).thenReturn(mockDataClient);
+
+ SeaTunnelRowType rowType =
+ new SeaTunnelRowType(
+ new String[] {"rowkey"},
+ new
org.apache.seatunnel.api.table.type.SeaTunnelDataType[] {
+ BasicType.STRING_TYPE
+ });
+
+ BigtableSourceReader reader =
+ new BigtableSourceReader(
+ PARAMETERS, mock(SourceReader.Context.class), rowType,
mockClient);
+ reader.open();
+ return reader;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void mockEmptyReadStream(BigtableSourceReader reader,
Runnable duringForEach)
+ throws Exception {
+ BigtableClient mockClient = mock(BigtableClient.class);
+ BigtableDataClient mockDataClient = mock(BigtableDataClient.class);
+ when(mockClient.getDataClient()).thenReturn(mockDataClient);
+
+ ServerStream<com.google.cloud.bigtable.data.v2.models.Row> fakeStream =
+ mock(ServerStream.class);
+ Mockito.doAnswer(
+ invocation -> {
+ if (duringForEach != null) {
+ duringForEach.run();
+ }
+ return null;
+ })
+ .when(fakeStream)
+ .forEach(any());
+ when(mockDataClient.readRows(any(Query.class))).thenReturn(fakeStream);
+
+ // Re-bind mocked data client on the already-opened reader
+ java.lang.reflect.Field clientField =
+ BigtableSourceReader.class.getDeclaredField("bigtableClient");
+ clientField.setAccessible(true);
+ clientField.set(reader, mockClient);
+ }
+
+ private static Collector<SeaTunnelRow> mockCollector() {
+ Collector<SeaTunnelRow> collector = mock(Collector.class);
+ when(collector.getCheckpointLock()).thenReturn(new Object());
+ return collector;
+ }
+
+ private static class TestingContext
+ implements SourceSplitEnumerator.Context<BigtableSourceSplit> {
+
+ private final int parallelism;
+ private final Set<Integer> registeredReaders = new HashSet<>();
+ private final Map<Integer, List<BigtableSourceSplit>> assignments =
new HashMap<>();
+
+ private TestingContext(int parallelism) {
+ this.parallelism = parallelism;
+ }
+
+ void registerReaderForTest(int subtaskId) {
+ registeredReaders.add(subtaskId);
+ }
+
+ void unregisterReaderForTest(int subtaskId) {
+ registeredReaders.remove(subtaskId);
+ }
+
+ void clearAssignments() {
+ assignments.clear();
+ }
+
+ int getAssignedSplitCount(int subtaskId) {
+ return assignments.getOrDefault(subtaskId,
Collections.emptyList()).size();
+ }
+
+ BigtableSourceSplit getLastAssignedSplit(int subtaskId) {
+ List<BigtableSourceSplit> splits = assignments.get(subtaskId);
+ return splits.get(splits.size() - 1);
+ }
+
+ @Override
+ public int currentParallelism() {
+ return parallelism;
+ }
+
+ @Override
+ public Set<Integer> registeredReaders() {
+ return registeredReaders;
+ }
+
+ @Override
+ public void assignSplit(int subtaskId, List<BigtableSourceSplit>
splits) {
+ assignments.computeIfAbsent(subtaskId, ignored -> new
ArrayList<>()).addAll(splits);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {}
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event)
{}
+
+ @Override
+ public MetricsContext getMetricsContext() {
+ return null;
+ }
+
+ @Override
+ public EventListener getEventListener() {
+ return null;
+ }
+ }
+}