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 630af04bc7 [Improve][Connector-V2] Add row-level resume for Bigtable
source chec… (#11359)
630af04bc7 is described below
commit 630af04bc7e392d90e621a9225672386bba6c46a
Author: 雷炯 <[email protected]>
AuthorDate: Wed Jul 8 22:44:02 2026 +0800
[Improve][Connector-V2] Add row-level resume for Bigtable source chec…
(#11359)
Co-authored-by: Cursor <[email protected]>
---
.../bigtable/source/BigtableSourceReader.java | 6 +-
.../bigtable/source/BigtableSourceSplit.java | 55 +++++++++++++--
.../bigtable/source/BigtableSourceReaderTest.java | 71 +++++++++++++++++++
.../bigtable/source/BigtableSourceSplitTest.java | 81 ++++++++++++++++++++++
4 files changed, 208 insertions(+), 5 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 17e41107aa..3dcfa310b2 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
@@ -137,6 +137,9 @@ public class BigtableSourceReader implements
SourceReader<SeaTunnelRow, Bigtable
SeaTunnelRow seaTunnelRow =
convertRow(bigtableRow);
synchronized (output.getCheckpointLock()) {
output.collect(seaTunnelRow);
+ // Update progress under the same lock as
collect for consistent
+ // snapshots
+
split.setLastReadRowKey(bigtableRow.getKey().toStringUtf8());
}
});
}
@@ -144,7 +147,8 @@ public class BigtableSourceReader implements
SourceReader<SeaTunnelRow, Bigtable
private Query buildQuery(BigtableSourceSplit split) {
Query query = Query.create(parameters.getTable());
- String startKey = split.getStartRowKey();
+ // Resume from lastReadRowKey after checkpoint restore when present
+ String startKey = split.getResumeStartRowKey();
String endKey = split.getEndRowKey();
if (!startKey.isEmpty() && !endKey.isEmpty()) {
query.range(startKey, endKey);
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplit.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplit.java
index 99e6782960..5276dde38c 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplit.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/main/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplit.java
@@ -21,21 +21,42 @@ import org.apache.seatunnel.api.source.SourceSplit;
import java.io.Serializable;
+/**
+ * Describes a bounded Bigtable scan split and optional in-split resume
progress.
+ *
+ * <p>When {@link #lastReadRowKey} is present, checkpoint restore resumes from
that row key
+ * (inclusive) instead of re-scanning from {@link #startRowKey}. Semantics are
at-least-once: the
+ * resume row may be read again after failover.
+ */
public class BigtableSourceSplit implements SourceSplit, Serializable {
+ /** Kept at {@code 1L} so existing checkpoint/savepoint bytes remain
deserializable. */
private static final long serialVersionUID = 1L;
+
public static final String SPLIT_PREFIX = "bigtable_source_split_";
private final String splitId;
- /** Inclusive start row key (empty means the table start). */
+ /** Inclusive start row key (empty means table start). */
private final String startRowKey;
- /** Exclusive end row key (empty means the table end). */
+ /** Exclusive end row key (empty means table end). */
private final String endRowKey;
+ /**
+ * Last successfully emitted row key (UTF-8), persisted in checkpoint
state. {@code null} for
+ * legacy checkpoints written before row-level resume was added.
+ */
+ private volatile String lastReadRowKey;
+
public BigtableSourceSplit(int splitIndex, String startRowKey, String
endRowKey) {
+ this(splitIndex, startRowKey, endRowKey, null);
+ }
+
+ public BigtableSourceSplit(
+ int splitIndex, String startRowKey, String endRowKey, String
lastReadRowKey) {
this.splitId = SPLIT_PREFIX + splitIndex;
this.startRowKey = startRowKey;
this.endRowKey = endRowKey;
+ this.lastReadRowKey = lastReadRowKey;
}
@Override
@@ -51,10 +72,36 @@ public class BigtableSourceSplit implements SourceSplit,
Serializable {
return endRowKey;
}
+ public String getLastReadRowKey() {
+ return lastReadRowKey;
+ }
+
+ /**
+ * Updates in-split progress; called under the same lock as {@code
collect()} so snapshots see
+ * the last emitted row.
+ *
+ * @param rowKeyUtf8 current row key (UTF-8)
+ */
+ void setLastReadRowKey(String rowKeyUtf8) {
+ this.lastReadRowKey = rowKeyUtf8;
+ }
+
+ /**
+ * Row key used as the inclusive scan start when building a Bigtable
{@code Query}.
+ *
+ * <p>Returns {@link #lastReadRowKey} when set, otherwise {@link
#startRowKey}.
+ */
+ public String getResumeStartRowKey() {
+ if (lastReadRowKey != null && !lastReadRowKey.isEmpty()) {
+ return lastReadRowKey;
+ }
+ return startRowKey;
+ }
+
@Override
public String toString() {
return String.format(
- "{\"split_id\":\"%s\", \"start\":\"%s\", \"end\":\"%s\"}",
- splitId, startRowKey, endRowKey);
+ "{\"split_id\":\"%s\", \"start\":\"%s\", \"end\":\"%s\",
\"last_read\":\"%s\"}",
+ splitId, startRowKey, endRowKey, lastReadRowKey);
}
}
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReaderTest.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReaderTest.java
index d1e8852028..e331ec593b 100644
---
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReaderTest.java
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceReaderTest.java
@@ -137,6 +137,77 @@ class BigtableSourceReaderTest {
"snapshotState taken during readSplit() must include the
in-flight split");
}
+ /**
+ * Checkpoint taken mid-scan must persist the last emitted rowkey so
failover can resume inside
+ * the split instead of re-scanning from split start.
+ */
+ @SuppressWarnings("unchecked")
+ @Test
+ void testSnapshotStateCapturesLastReadRowKeyDuringScan() throws Exception {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "a", "z");
+ final List<BigtableSourceSplit>[] capturedState = new List[1];
+
+ Row fakeRow = mock(Row.class);
+ RowCell cell = mock(RowCell.class);
+ when(cell.getFamily()).thenReturn("cf");
+ when(cell.getQualifier()).thenReturn(ByteString.copyFromUtf8("name"));
+ when(cell.getValue()).thenReturn(ByteString.copyFromUtf8("bob"));
+ when(fakeRow.getCells()).thenReturn(Collections.singletonList(cell));
+ when(fakeRow.getKey()).thenReturn(ByteString.copyFromUtf8("row-490"));
+
+ BigtableSourceReader reader = createOpenedReader(parameters, rowType);
+ reader.addSplits(Collections.singletonList(split));
+
+ ServerStream<Row> fakeStream = mock(ServerStream.class);
+ Mockito.doAnswer(
+ invocation -> {
+ Consumer<Row> action = invocation.getArgument(0);
+ action.accept(fakeRow);
+ capturedState[0] = reader.snapshotState(99L);
+ return null;
+ })
+ .when(fakeStream)
+ .forEach(any());
+ when(mockDataClient.readRows(any(Query.class))).thenReturn(fakeStream);
+
+ Collector<SeaTunnelRow> collector = mock(Collector.class);
+ when(collector.getCheckpointLock()).thenReturn(new Object());
+
+ reader.pollNext(collector);
+
+ assertEquals(1, capturedState[0].size());
+ assertEquals("row-490", capturedState[0].get(0).getLastReadRowKey());
+ assertEquals("row-490",
capturedState[0].get(0).getResumeStartRowKey());
+ }
+
+ /**
+ * A split restored from checkpoint with {@code lastReadRowKey} must use
that key as the scan
+ * start when {@link BigtableSourceReader} issues the read request.
+ */
+ @SuppressWarnings("unchecked")
+ @Test
+ void testRestoredSplitResumesFromLastReadRowKey() throws Exception {
+ BigtableSourceSplit restoredSplit = new BigtableSourceSplit(0, "a",
"z", "row-490");
+
+ ServerStream<Row> fakeStream = mock(ServerStream.class);
+ Mockito.doAnswer(invocation -> null).when(fakeStream).forEach(any());
+ ArgumentCaptor<Query> queryCaptor =
ArgumentCaptor.forClass(Query.class);
+
when(mockDataClient.readRows(queryCaptor.capture())).thenReturn(fakeStream);
+
+ BigtableSourceReader reader = createOpenedReader(parameters, rowType);
+ reader.addSplits(Collections.singletonList(restoredSplit));
+
+ Collector<SeaTunnelRow> collector = mock(Collector.class);
+ when(collector.getCheckpointLock()).thenReturn(new Object());
+
+ reader.pollNext(collector);
+
+ assertEquals("row-490", restoredSplit.getResumeStartRowKey());
+ assertTrue(
+ queryCaptor.getValue().toString().contains("row-490"),
+ "Query should scan from restored lastReadRowKey");
+ }
+
/**
* After readSplit() completes, currentSplit is cleared. A snapshot taken
after that must NOT
* re-include the already-finished split.
diff --git
a/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitTest.java
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitTest.java
new file mode 100644
index 0000000000..3c911c62c6
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-google-bigtable/src/test/java/org/apache/seatunnel/connectors/seatunnel/bigtable/source/BigtableSourceSplitTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.common.utils.SerializationUtils;
+
+import org.apache.commons.codec.binary.Base64;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/** Tests for {@link BigtableSourceSplit} resume progress and checkpoint
serialization. */
+class BigtableSourceSplitTest {
+
+ /**
+ * Bytes produced by {@code BigtableSourceSplit(1, "x", "y")} on dev
before {@code
+ * lastReadRowKey} existed (serialVersionUID = 1L, three fields only).
+ */
+ private static final String LEGACY_SPLIT_V1_BASE64 =
+
"rO0ABXNyAE1vcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5iaWd0YWJsZS5zb3VyY2UuQmlndGFibGVTb3VyY2VTcGxpdAAAAAAAAAABAgADTAAJZW5kUm93S2V5dAASTGphdmEvbGFuZy9TdHJpbmc7TAAHc3BsaXRJZHEAfgABTAALc3RhcnRSb3dLZXlxAH4AAXhwdAABeXQAF2JpZ3RhYmxlX3NvdXJjZV9zcGxpdF8xdAABeA==";
+
+ @Test
+ void resumeStartRowKeyFallsBackToSplitStartWhenNoProgress() {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "a", "z");
+ assertEquals("a", split.getResumeStartRowKey());
+ }
+
+ @Test
+ void resumeStartRowKeyUsesLastReadRowKeyWhenPresent() {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "a", "z",
"row-490");
+ assertEquals("row-490", split.getResumeStartRowKey());
+ }
+
+ @Test
+ void lastReadRowKeyCanBeUpdatedDuringRead() {
+ BigtableSourceSplit split = new BigtableSourceSplit(0, "", "");
+ split.setLastReadRowKey("key-42");
+ assertEquals("key-42", split.getLastReadRowKey());
+ assertEquals("key-42", split.getResumeStartRowKey());
+ }
+
+ @Test
+ void checkpointRoundTripPreservesLastReadRowKey() {
+ BigtableSourceSplit original = new BigtableSourceSplit(0, "start",
"end", "resume-here");
+ byte[] bytes = SerializationUtils.serialize(original);
+ BigtableSourceSplit restored = SerializationUtils.deserialize(bytes);
+ assertEquals(original.splitId(), restored.splitId());
+ assertEquals("start", restored.getStartRowKey());
+ assertEquals("end", restored.getEndRowKey());
+ assertEquals("resume-here", restored.getLastReadRowKey());
+ assertEquals("resume-here", restored.getResumeStartRowKey());
+ }
+
+ @Test
+ void deserializesLegacyCheckpointBytesFromDevRelease() {
+ byte[] legacyBytes = Base64.decodeBase64(LEGACY_SPLIT_V1_BASE64);
+ BigtableSourceSplit restored =
SerializationUtils.deserialize(legacyBytes);
+ assertEquals("bigtable_source_split_1", restored.splitId());
+ assertEquals("x", restored.getStartRowKey());
+ assertEquals("y", restored.getEndRowKey());
+ assertNull(restored.getLastReadRowKey());
+ assertEquals("x", restored.getResumeStartRowKey());
+ }
+}