This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new bbc790ed70a branch-4.2: [fix](streaming-job) Avoid redundant schema
persistence in CDC offset journals #69129 (#69180)
bbc790ed70a is described below
commit bbc790ed70a300871428e3fda0e2b6b71e489090
Author: wudi <[email protected]>
AuthorDate: Fri Oct 16 23:28:16 2026 +0800
branch-4.2: [fix](streaming-job) Avoid redundant schema persistence in CDC
offset journals #69129 (#69180)
Cherry-picked from #69129
---
.../insert/streaming/StreamingInsertJob.java | 31 +-
.../doris/job/offset/SourceOffsetProvider.java | 5 +
.../job/offset/jdbc/JdbcSourceOffsetProvider.java | 46 ++-
.../offset/jdbc/JdbcTvfSourceOffsetProvider.java | 6 +
.../java/org/apache/doris/persist/EditLog.java | 4 +
.../StreamingInsertJobOffsetPersistenceTest.java | 5 +
.../StreamingInsertJobSchemaPersistenceTest.java | 435 +++++++++++++++++++++
.../jdbc/JdbcSourceOffsetProviderOffsetTest.java | 79 ++++
8 files changed, 602 insertions(+), 9 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index f5fb8b090a5..71f94723427 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -85,6 +85,7 @@ import org.apache.doris.transaction.TransactionState;
import org.apache.doris.transaction.TxnStateChangeCallback;
import com.google.common.base.Preconditions;
+import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import lombok.Getter;
import lombok.Setter;
@@ -562,7 +563,9 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
}
offsetProvider.updateOffset(offset);
offsetProvider.resetLag();
- this.offsetProviderPersist =
offsetProvider.getPersistInfo();
+ String persistInfo = offsetProvider.getPersistInfo();
+ // A paused provider may not have restored its schema from
the image yet.
+ this.offsetProviderPersist =
offsetProvider.mergePersistInfo(offsetProviderPersist, persistInfo);
log.info("Alter streaming job {} offset to {}",
getJobId(), offsetProperty);
}
modifyPropertiesInternal(alterJobCommand.getProperties());
@@ -1027,7 +1030,9 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
this.jobStatistic.setFileSize(attachment.getFileBytes());
this.jobStatistic.setFilteredRows(attachment.getFilteredRows());
offsetProvider.updateOffset(offsetProvider.deserializeOffset(attachment.getOffset()));
- this.offsetProviderPersist = offsetProvider.getPersistInfo();
+ String persistInfo = offsetProvider.getPersistInfo();
+ // Preserve cached schema until the provider is restored.
+ this.offsetProviderPersist =
offsetProvider.mergePersistInfo(offsetProviderPersist, persistInfo);
//update metric
if (MetricRepo.isInit && !isReplay) {
@@ -1102,6 +1107,10 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
* @param replayJob
*/
public void replayOnUpdated(StreamingInsertJob replayJob) {
+ String replayPersistInfo = replayJob.getOffsetProviderPersist();
+ if (replayPersistInfo != null) {
+ replayPersistInfo =
offsetProvider.mergePersistInfo(offsetProviderPersist, replayPersistInfo);
+ }
if (!JobStatus.RUNNING.equals(replayJob.getJobStatus())) {
// No need to restore in the running state, as scheduling relies
on pending states.
// insert TVF does not persist the running state.
@@ -1114,8 +1123,8 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
}
try {
modifyPropertiesInternal(replayJob.getProperties());
- if (replayJob.getOffsetProviderPersist() != null) {
- setOffsetProviderPersist(replayJob.getOffsetProviderPersist());
+ if (replayPersistInfo != null) {
+ setOffsetProviderPersist(replayPersistInfo);
if (tvfType != null && !Config.isCloudMode()) {
offsetProvider.restoreFromPersistInfo(offsetProviderPersist);
}
@@ -1124,7 +1133,8 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
String offset =
replayJob.getProperties().get(StreamingJobProperties.OFFSET_PROPERTY);
if (StringUtils.isNotEmpty(offset)) {
offsetProvider.updateOffset(validateOffset(offset));
- this.offsetProviderPersist =
offsetProvider.getPersistInfo();
+ this.offsetProviderPersist =
offsetProvider.mergePersistInfo(
+ offsetProviderPersist,
offsetProvider.getPersistInfo());
}
}
// When the pause state is restarted, it also needs to be updated
@@ -1403,6 +1413,13 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
Text.writeString(out, GsonUtils.GSON.toJson(this));
}
+ protected void logUpdateOperation(String persistInfo) {
+ JsonObject journalJson = GsonUtils.GSON.toJsonTree(this,
StreamingInsertJob.class).getAsJsonObject();
+ journalJson.addProperty("opp", persistInfo);
+ // Freeze only the journal payload; the job retains its complete state
for images.
+
Env.getCurrentEnv().getEditLog().logUpdateJob(GsonUtils.GSON.toJson(journalJson));
+ }
+
@Override
public long getId() {
return getJobId();
@@ -1736,7 +1753,9 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
return;
}
- logUpdateOperation();
+ logUpdateOperation(jdbcOffsetProvider.isTableSchemasDirty()
+ ? offsetProviderPersist :
jdbcOffsetProvider.getPersistInfoWithoutTableSchemas());
+ jdbcOffsetProvider.markTableSchemasPersisted();
lastOffsetPersistTimeMs = currentTimeMs;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
index 624e6a431dd..2962a672ec7 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
@@ -150,6 +150,11 @@ public interface SourceOffsetProvider {
return null;
}
+ /** Merge serialized state without initializing the provider; full
replacement by default. */
+ default String mergePersistInfo(String previousPersistInfo, String
incomingPersistInfo) {
+ return incomingPersistInfo;
+ }
+
/**
* Restore offset from an image or job update journal, including for
PAUSED jobs.
*/
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
index 37e7136743e..e5ffe6ab25e 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
@@ -53,6 +53,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
+import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import lombok.Getter;
import lombok.Setter;
@@ -82,6 +83,7 @@ import java.util.stream.Collectors;
@Log4j2
public class JdbcSourceOffsetProvider implements SourceOffsetProvider {
public static final String SPLIT_ID = "splitId";
+ private static final String TABLE_SCHEMAS_FIELD = "ts";
private static final ObjectMapper objectMapper = new ObjectMapper();
protected int snapshotParallelism = 1;
protected Long jobId;
@@ -100,8 +102,9 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
@SerializedName("bop")
Map<String, String> binlogOffsetPersist;
- @SerializedName("ts")
+ @SerializedName(TABLE_SCHEMAS_FIELD)
String tableSchemas;
+ private transient boolean tableSchemasDirty;
/** Split progress (task-commit view). */
@SerializedName("csp")
@@ -294,7 +297,7 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
public boolean shouldPersistOffset(long lastPersistTimeMs, long
currentTimeMs) {
synchronized (splitsLock) {
- if (currentOffset == null || !currentOffset.snapshotSplit()) {
+ if (tableSchemasDirty || currentOffset == null ||
!currentOffset.snapshotSplit()) {
return true;
}
}
@@ -758,7 +761,44 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
@Override
public String getPersistInfo() {
- return GsonUtils.GSON.toJson(this);
+ synchronized (splitsLock) {
+ return GsonUtils.GSON.toJson(this);
+ }
+ }
+
+ public String getPersistInfoWithoutTableSchemas() {
+ synchronized (splitsLock) {
+ JsonObject persisted =
GsonUtils.GSON.toJsonTree(this).getAsJsonObject();
+ persisted.remove(TABLE_SCHEMAS_FIELD);
+ return GsonUtils.GSON.toJson(persisted);
+ }
+ }
+
+ public void setTableSchemas(String tableSchemas) {
+ synchronized (splitsLock) {
+ this.tableSchemas = tableSchemas;
+ tableSchemasDirty = true;
+ }
+ }
+
+ // The job write lock spans payload creation, journal acknowledgement and
this call.
+ public void markTableSchemasPersisted() {
+ synchronized (splitsLock) {
+ tableSchemasDirty = false;
+ }
+ }
+
+ @Override
+ public String mergePersistInfo(String previousPersistInfo, String
incomingPersistInfo) {
+ JsonObject incoming = GsonUtils.GSON.fromJson(incomingPersistInfo,
JsonObject.class);
+ if (previousPersistInfo == null
+ || (incoming.has(TABLE_SCHEMAS_FIELD) &&
!incoming.get(TABLE_SCHEMAS_FIELD).isJsonNull())) {
+ return incomingPersistInfo;
+ }
+ // Missing schema means no update, just as in a CDC offset commit
request.
+ JsonObject previous = GsonUtils.GSON.fromJson(previousPersistInfo,
JsonObject.class);
+ incoming.add(TABLE_SCHEMAS_FIELD, previous.get(TABLE_SCHEMAS_FIELD));
+ return GsonUtils.GSON.toJson(incoming);
}
// ============ Async split progress (driven by scheduler each tick)
============
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
index 84cc3a94a53..0c5b72fccdc 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
@@ -436,6 +436,12 @@ public class JdbcTvfSourceOffsetProvider extends
JdbcSourceOffsetProvider {
return remapped;
}
+ @Override
+ public String mergePersistInfo(String previousPersistInfo, String
incomingPersistInfo) {
+ // TVF records contain complete state rather than schema-omitting CDC
updates.
+ return incomingPersistInfo;
+ }
+
/**
* Restore chw/bop/ts from image or job update journal JSON; preserves
cumulative snapshot progress.
*/
diff --git a/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java
b/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java
index ea93b941cf1..c5450abd6ba 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java
@@ -2107,6 +2107,10 @@ public class EditLog {
logEdit(OperationType.OP_UPDATE_SCHEDULER_JOB, job);
}
+ public void logUpdateJob(String serializedJob) {
+ logEdit(OperationType.OP_UPDATE_SCHEDULER_JOB, out ->
Text.writeString(out, serializedJob));
+ }
+
public void logDeleteJob(AbstractJob job) {
logEdit(OperationType.OP_DELETE_SCHEDULER_JOB, job);
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
index 84055842cb1..65d94c8e672 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
@@ -506,6 +506,11 @@ public class StreamingInsertJobOffsetPersistenceTest {
private static class TestStreamingInsertJob extends StreamingInsertJob {
private int journalCount;
+ @Override
+ protected void logUpdateOperation(String persistInfo) {
+ journalCount++;
+ }
+
@Override
public void logUpdateOperation() {
journalCount++;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobSchemaPersistenceTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobSchemaPersistenceTest.java
new file mode 100644
index 00000000000..3500d2ddd39
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobSchemaPersistenceTest.java
@@ -0,0 +1,435 @@
+// 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.doris.job.extensions.insert.streaming;
+
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.io.Text;
+import org.apache.doris.common.io.Writable;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.job.base.AbstractJob;
+import org.apache.doris.job.cdc.DataSourceConfigKeys;
+import org.apache.doris.job.cdc.request.CommitOffsetRequest;
+import org.apache.doris.job.cdc.split.SnapshotSplit;
+import org.apache.doris.job.common.DataSourceType;
+import org.apache.doris.job.common.JobStatus;
+import org.apache.doris.job.common.TaskStatus;
+import org.apache.doris.job.manager.JobManager;
+import org.apache.doris.job.offset.jdbc.JdbcSourceOffsetProvider;
+import org.apache.doris.journal.Journal;
+import org.apache.doris.journal.JournalEntity;
+import org.apache.doris.nereids.trees.plans.commands.AlterJobCommand;
+import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.OperationType;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.transaction.GlobalTransactionMgrIface;
+import org.apache.doris.transaction.TxnStateCallbackFactory;
+
+import com.google.common.base.Strings;
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class StreamingInsertJobSchemaPersistenceTest {
+ private final List<byte[]> journals = new ArrayList<>();
+ private final List<Writable> payloads = new ArrayList<>();
+ private MockedStatic<Env> envMock;
+ private boolean oldBatchLog;
+ private boolean failNextWrite;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ oldBatchLog = Config.enable_batch_editlog;
+ Config.enable_batch_editlog = true;
+ envMock = Mockito.mockStatic(Env.class);
+ Env env = Mockito.mock(Env.class);
+ envMock.when(Env::getCurrentEnv).thenReturn(env);
+ InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+ envMock.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+
Mockito.when(catalog.getDbOrAnalysisException("schema_persistence_test"))
+ .thenReturn(new Database(9002L, "schema_persistence_test"));
+ GlobalTransactionMgrIface transactions =
Mockito.mock(GlobalTransactionMgrIface.class);
+
envMock.when(Env::getCurrentGlobalTransactionMgr).thenReturn(transactions);
+ Mockito.when(transactions.getCallbackFactory()).thenReturn(new
TxnStateCallbackFactory());
+
+ EditLog editLog = Mockito.mock(EditLog.class,
Mockito.CALLS_REAL_METHODS);
+ Mockito.doReturn(1).when(editLog).getNumEditStreams();
+ Deencapsulation.setField(editLog, "numTransactions", new AtomicLong());
+ Deencapsulation.setField(editLog, "totalTimeTransactions", new
AtomicLong());
+ Mockito.when(env.getEditLog()).thenReturn(editLog);
+ Answer<Long> capture = invocation -> {
+ short op = invocation.getArgument(0);
+ Writable payload = invocation.getArgument(1);
+ if (failNextWrite) {
+ failNextWrite = false;
+ throw new IllegalStateException("injected journal failure");
+ }
+ // Exercise serialization on a different thread while commitOffset
still holds the job lock.
+ byte[] bytes = CompletableFuture.supplyAsync(() ->
encodeJournal(op, payload)).get(10, TimeUnit.SECONDS);
+ journals.add(bytes);
+ payloads.add(payload);
+ return (long) journals.size();
+ };
+
Mockito.doAnswer(capture).when(editLog).logEditWithQueue(Mockito.anyShort(),
Mockito.any(Writable.class));
+ Journal journal = Mockito.mock(Journal.class);
+ Mockito.doAnswer(capture).when(journal).write(Mockito.anyShort(),
Mockito.any(Writable.class));
+ Deencapsulation.setField(editLog, "journal", journal);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ Config.enable_batch_editlog = oldBatchLog;
+ envMock.close();
+ }
+
+ @Test
+ public void testClientSchemaUpdatesAreWrittenAndNullIsOmitted() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ job.commitOffset(request("200", null));
+ job.commitOffset(request("300", "schema-A"));
+ job.commitOffset(request("400", "schema-B"));
+ job.commitOffset(request("500", null));
+
+ Assertions.assertEquals("schema-A", loggedSchema(0));
+ Assertions.assertNull(loggedSchema(1));
+ Assertions.assertEquals("schema-A", loggedSchema(2));
+ Assertions.assertEquals("schema-B", loggedSchema(3));
+ Assertions.assertNull(loggedSchema(4));
+ Assertions.assertEquals("schema-B", ((JdbcSourceOffsetProvider)
job.offsetProvider).getTableSchemas());
+ Assertions.assertArrayEquals(journals.get(0),
+ encodeJournal(OperationType.OP_UPDATE_SCHEDULER_JOB,
payloads.get(0)));
+ }
+
+ @Test
+ public void testFullJournalEncodingMatchesJobWriter() throws Exception {
+ StreamingInsertJob job = newJob();
+ ((JdbcSourceOffsetProvider)
job.offsetProvider).setTableSchemas("schema-A");
+ job.setOffsetProviderPersist(job.offsetProvider.getPersistInfo());
+ byte[] expected = encodeJournal(OperationType.OP_UPDATE_SCHEDULER_JOB,
job);
+
+ job.logUpdateOperation(job.getOffsetProviderPersist());
+
+ Assertions.assertArrayEquals(expected, journals.get(0));
+ }
+
+ @Test
+ public void testCheckpointAndFailoverRetainSchemaWithoutScheduling()
throws Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ job.commitOffset(request("200", null));
+ job.commitOffset(request("300", "schema-B"));
+ job.commitOffset(request("400", null));
+ JobManager<StreamingInsertJob, Map<Object, Object>> manager = new
JobManager<>();
+ Map<Long, StreamingInsertJob> jobs = Deencapsulation.getField(manager,
"jobMap");
+ jobs.put(9001L, newJob());
+ for (byte[] journal : journals) {
+ manager.replayUpdateJob(readJournal(journal));
+ }
+
+ ByteArrayOutputStream image = new ByteArrayOutputStream();
+ manager.write(new DataOutputStream(image));
+ JobManager<StreamingInsertJob, Map<Object, Object>> restored = new
JobManager<>();
+ restored.readFields(new DataInputStream(new
ByteArrayInputStream(image.toByteArray())));
+ StreamingInsertJob recovered = restored.getJob(9001L);
+ assertState(recovered, "schema-B", "400");
+
+ job.commitOffset(request("500", null));
+ restored.replayUpdateJob(readJournal(journals.get(4)));
+ assertState(recovered, "schema-B", "500");
+ recovered.replayOffsetProviderIfNeed();
+ Assertions.assertEquals("schema-B", ((JdbcSourceOffsetProvider)
recovered.offsetProvider).getTableSchemas());
+ assertState(recovered, "schema-B", "500");
+ Deencapsulation.setField(recovered, "runningStreamTask", new
NoopTask());
+ recovered.commitOffset(request("600", null));
+ Assertions.assertNull(loggedSchema(5));
+ }
+
+ @Test
+ public void testFailureRetainsSchemaForSameSchemaAndNullRetries() throws
Exception {
+ StreamingInsertJob job = newJob();
+ for (String retrySchema : new String[] {"schema-A", null}) {
+ failNextWrite = true;
+ String schema = retrySchema == null ? "schema-B" : "schema-A";
+ Assertions.assertThrows(IllegalStateException.class, () ->
job.commitOffset(request("100", schema)));
+ job.commitOffset(request("200", retrySchema));
+ Assertions.assertEquals(schema, loggedSchema(journals.size() - 1));
+ job.commitOffset(request("300", null));
+ Assertions.assertNull(loggedSchema(journals.size() - 1));
+ }
+ }
+
+ @Test
+ public void testSchemaChangeBypassesSnapshotThrottle() throws Exception {
+ StreamingInsertJob job = newJob();
+ JdbcSourceOffsetProvider provider = (JdbcSourceOffsetProvider)
job.offsetProvider;
+ for (int i = 0; i < 3; i++) {
+ String splitId = "table:" + i;
+ provider.getRemainingSplits().add(new SnapshotSplit(splitId,
"db.table",
+ Collections.singletonList("id"), new Object[] {1L}, new
Object[] {2L}, null));
+ CommitOffsetRequest request = request("100", i == 1 ? "schema-A" :
null);
+ request.setOffset("[{\"splitId\":\"" + splitId +
"\",\"lsn\":\"100\"}]");
+ job.commitOffset(request);
+ }
+ Assertions.assertEquals(2, journals.size());
+ Assertions.assertEquals("schema-A", loggedSchema(1));
+ }
+
+ @Test
+ public void testOrdinaryJournalSizeDoesNotGrowWithSchema() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", Strings.repeat("x", 1024)));
+ job.commitOffset(request("200", null));
+ int smallJournalSize = journals.get(1).length;
+ job.commitOffset(request("300", Strings.repeat("x", 1024 * 1024)));
+ job.commitOffset(request("400", null));
+ Assertions.assertTrue(journals.get(2).length > 1024 * 1024);
+ Assertions.assertTrue(journals.get(3).length < 10_000);
+ Assertions.assertTrue(Math.abs(smallJournalSize -
journals.get(3).length) < 100);
+ }
+
+ @Test
+ public void testFullJobStateSurvivesCompactOffsetJournal() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+
+ job.commitOffset(request("200", null));
+ StreamingTaskTxnCommitAttachment attachment = new
StreamingTaskTxnCommitAttachment(
+ 9001L, 1001L, 0, 0, 0, 0, 0, request("300", null).getOffset());
+ Deencapsulation.invoke(job, "updateCloudJobStatisticAndOffset",
attachment, true);
+ job.logUpdateOperation();
+
+ Assertions.assertNull(loggedSchema(1));
+ Assertions.assertEquals("schema-A", loggedSchema(2));
+ assertState(job, "schema-A", "300");
+ assertState(GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(job),
StreamingInsertJob.class),
+ "schema-A", "300");
+ }
+
+ @Test
+ public void testCloudOffsetRecoveryBeforeProviderRestoreKeepsSchema()
throws Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ StreamingInsertJob recovered = readJournal(journals.get(0));
+ Assertions.assertNull(((JdbcSourceOffsetProvider)
recovered.offsetProvider).getTableSchemas());
+
+ StreamingTaskTxnCommitAttachment attachment = new
StreamingTaskTxnCommitAttachment(
+ 9001L, 1001L, 0, 0, 0, 0, 0, request("200", null).getOffset());
+ Deencapsulation.invoke(recovered, "updateCloudJobStatisticAndOffset",
attachment, true);
+ assertState(recovered, "schema-A", "200");
+ recovered.replayOffsetProviderIfNeed();
+ Assertions.assertEquals("schema-A", ((JdbcSourceOffsetProvider)
recovered.offsetProvider).getTableSchemas());
+ }
+
+ @Test
+ public void testDirectJournalOmitsUnchangedSchema() throws Exception {
+ Config.enable_batch_editlog = false;
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ job.commitOffset(request("200", null));
+ Assertions.assertEquals("schema-A", loggedSchema(0));
+ Assertions.assertNull(loggedSchema(1));
+ JsonObject persisted =
GsonUtils.GSON.fromJson(loggedJob(1).get("opp").getAsString(),
JsonObject.class);
+ Assertions.assertFalse(persisted.has("ts"));
+ StreamingInsertJob recovered = readJournal(journals.get(0));
+ recovered.replayOnUpdated(readJournal(journals.get(1)));
+ assertState(recovered, "schema-A", "200");
+ }
+
+ @Test
+ public void testRecordWithoutSchemaRetainsPreviousSchema() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ StreamingInsertJob recovered = readJournal(journals.get(0));
+ newJob().commitOffset(request("200", null));
+ recovered.replayOnUpdated(readJournal(journals.get(1)));
+ assertState(recovered, "schema-A", "200");
+ }
+
+ @Test
+ public void testFullImageAndAlterOffsetKeepSchema() throws Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ job.commitOffset(request("200", null));
+ job.setJobStatus(JobStatus.PAUSED);
+ job.alterJob(new AlterJobCommand(job.getJobName(),
+
Collections.singletonMap(StreamingJobProperties.OFFSET_PROPERTY,
"{\"lsn\":\"300\"}"),
+ null, null, null, Collections.emptyMap(),
Collections.emptyMap()));
+ ByteArrayOutputStream image = new ByteArrayOutputStream();
+ job.write(new DataOutputStream(image));
+ StreamingInsertJob restored = (StreamingInsertJob)
AbstractJob.readFields(
+ new DataInputStream(new
ByteArrayInputStream(image.toByteArray())));
+ assertState(restored, "schema-A", "300");
+ StreamingInsertJob follower = readJournal(journals.get(0));
+ follower.replayOnUpdated(readJournal(journals.get(2)));
+ assertState(follower, "schema-A", "300");
+ }
+
+ @Test
+ public void testAlterPausedJobAfterImageRecoveryKeepsSchema() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ job.setJobStatus(JobStatus.PAUSED);
+ ByteArrayOutputStream image = new ByteArrayOutputStream();
+ job.write(new DataOutputStream(image));
+ StreamingInsertJob recovered = (StreamingInsertJob)
AbstractJob.readFields(
+ new DataInputStream(new
ByteArrayInputStream(image.toByteArray())));
+
+ recovered.alterJob(new AlterJobCommand(recovered.getJobName(),
+
Collections.singletonMap(StreamingJobProperties.OFFSET_PROPERTY,
"{\"lsn\":\"200\"}"),
+ null, null, null, Collections.emptyMap(),
Collections.emptyMap()));
+ assertState(recovered, "schema-A", "200");
+ image.reset();
+ recovered.write(new DataOutputStream(image));
+ assertState((StreamingInsertJob) AbstractJob.readFields(
+ new DataInputStream(new
ByteArrayInputStream(image.toByteArray()))), "schema-A", "200");
+
+ recovered.setJobStatus(JobStatus.RUNNING);
+ recovered.replayOffsetProviderIfNeed();
+ Assertions.assertEquals("schema-A", ((JdbcSourceOffsetProvider)
recovered.offsetProvider).getTableSchemas());
+ Deencapsulation.setField(recovered, "runningStreamTask", new
NoopTask());
+ recovered.commitOffset(request("300", null));
+ assertState(recovered, "schema-A", "300");
+ }
+
+ @Test
+ public void testLegacyOffsetJournalRetainsPreviousSchema() throws
Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ StreamingInsertJob recovered = readJournal(journals.get(0));
+ StreamingInsertJob legacyUpdate = newJob();
+ legacyUpdate.setJobStatus(JobStatus.PAUSED);
+
legacyUpdate.getProperties().put(StreamingJobProperties.OFFSET_PROPERTY,
"{\"lsn\":\"200\"}");
+
+ recovered.replayOnUpdated(legacyUpdate);
+
+ assertState(recovered, "schema-A", "200");
+ }
+
+ @Test
+ public void testStatusJournalAfterRecoveryRetainsSchemaWithoutScheduling()
throws Exception {
+ StreamingInsertJob job = newJob();
+ job.commitOffset(request("100", "schema-A"));
+ StreamingInsertJob recovered = readJournal(journals.get(0));
+ recovered.setJobStatus(JobStatus.PAUSED);
+ recovered.logUpdateOperation();
+ Assertions.assertEquals("schema-A", loggedSchema(1));
+
+ StreamingInsertJob follower = readJournal(journals.get(0));
+ follower.replayOnUpdated(readJournal(journals.get(1)));
+ assertState(follower, "schema-A", "100");
+ ByteArrayOutputStream image = new ByteArrayOutputStream();
+ follower.write(new DataOutputStream(image));
+ assertState((StreamingInsertJob) AbstractJob.readFields(
+ new DataInputStream(new
ByteArrayInputStream(image.toByteArray()))), "schema-A", "100");
+ }
+
+ private static StreamingInsertJob newJob() {
+ StreamingInsertJob job = new StreamingInsertJob();
+ job.setJobId(9001L);
+ job.setJobName("schema_persistence_test");
+ job.setCurrentDbName("schema_persistence_test");
+ job.setJobStatus(JobStatus.RUNNING);
+ Deencapsulation.setField(job, "dataSourceType",
DataSourceType.POSTGRES);
+ Deencapsulation.setField(job, "properties", new HashMap<String,
String>());
+ Map<String, String> sourceProperties = new HashMap<>(
+ Collections.singletonMap(DataSourceConfigKeys.JDBC_URL,
"jdbc:postgresql://127.0.0.1:5432/source_db"));
+ Deencapsulation.setField(job, "sourceProperties", sourceProperties);
+ Deencapsulation.setField(job, "targetProperties", new HashMap<String,
String>());
+ Deencapsulation.setField(job, "runningStreamTask", new NoopTask());
+ job.offsetProvider = new JdbcSourceOffsetProvider(9001L,
DataSourceType.POSTGRES, sourceProperties);
+ return job;
+ }
+
+ private static CommitOffsetRequest request(String lsn, String schema) {
+ CommitOffsetRequest request = new CommitOffsetRequest();
+ request.setTaskId(1001L);
+ request.setOffset("[{\"splitId\":\"binlog-split\",\"lsn\":\"" + lsn +
"\"}]");
+ request.setTableSchemas(schema);
+ return request;
+ }
+
+ private static byte[] encodeJournal(short op, Writable payload) {
+ try {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ DataOutputStream out = new DataOutputStream(bytes);
+ out.writeShort(op);
+ payload.write(out);
+ return bytes.toByteArray();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static StreamingInsertJob readJournal(byte[] bytes) throws
Exception {
+ JournalEntity entity = new JournalEntity();
+ entity.readFields(new DataInputStream(new
ByteArrayInputStream(bytes)));
+ Assertions.assertEquals(OperationType.OP_UPDATE_SCHEDULER_JOB,
entity.getOpCode());
+ return (StreamingInsertJob) entity.getData();
+ }
+
+ private JsonObject loggedJob(int index) throws Exception {
+ DataInputStream in = new DataInputStream(new
ByteArrayInputStream(journals.get(index)));
+ Assertions.assertEquals(OperationType.OP_UPDATE_SCHEDULER_JOB,
in.readShort());
+ return GsonUtils.GSON.fromJson(Text.readString(in), JsonObject.class);
+ }
+
+ private String loggedSchema(int index) throws Exception {
+ JsonObject offset =
GsonUtils.GSON.fromJson(loggedJob(index).get("opp").getAsString(),
JsonObject.class);
+ return offset.has("ts") ? offset.get("ts").getAsString() : null;
+ }
+
+ private static void assertState(StreamingInsertJob job, String schema,
String lsn) {
+ JdbcSourceOffsetProvider state = GsonUtils.GSON.fromJson(
+ job.getOffsetProviderPersist(),
JdbcSourceOffsetProvider.class);
+ Assertions.assertEquals(schema, state.getTableSchemas());
+ Assertions.assertEquals(lsn,
state.getBinlogOffsetPersist().get("lsn"));
+ }
+
+ private static class NoopTask extends StreamingMultiTblTask {
+ NoopTask() {
+ super(9001L, 1001L, null, null, null, null, null,
+ new StreamingJobProperties(new HashMap<>()), null, null);
+ Deencapsulation.setField(this, "status", TaskStatus.RUNNING);
+ }
+
+ @Override
+ public void successCallback(CommitOffsetRequest offsetRequest) {
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
index 1ea32f914a1..cbdaa98277e 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
@@ -25,7 +25,10 @@ import org.apache.doris.job.cdc.split.BinlogSplit;
import org.apache.doris.job.cdc.split.SnapshotSplit;
import org.apache.doris.job.common.DataSourceType;
import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
+import org.apache.doris.job.offset.SourceOffsetProvider;
+import org.apache.doris.persist.gson.GsonUtils;
+import com.google.gson.JsonObject;
import org.junit.Assert;
import org.junit.Test;
@@ -145,6 +148,82 @@ public class JdbcSourceOffsetProviderOffsetTest {
Assert.assertTrue(provider.shouldPersistOffset(1_000L, 1_001L));
}
+ @Test
+ public void testSchemaRemainsPendingUntilJournalAcknowledgement() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ provider.setTableSchemas("schema-A");
+ Assert.assertEquals("schema-A", GsonUtils.GSON.fromJson(
+ provider.getPersistInfo(),
JdbcSourceOffsetProvider.class).getTableSchemas());
+ // Preparing a record, or receiving the same schema again, must not
acknowledge it.
+ provider.setTableSchemas("schema-A");
+ Assert.assertEquals("schema-A", GsonUtils.GSON.fromJson(
+ provider.getPersistInfo(),
JdbcSourceOffsetProvider.class).getTableSchemas());
+ Assert.assertTrue(provider.isTableSchemasDirty());
+
+ provider.markTableSchemasPersisted();
+ JsonObject delta =
GsonUtils.GSON.fromJson(provider.getPersistInfoWithoutTableSchemas(),
JsonObject.class);
+ Assert.assertFalse(delta.has("ts"));
+ Assert.assertFalse(provider.isTableSchemasDirty());
+ Assert.assertEquals("schema-A", provider.getTableSchemas());
+
+ provider.setTableSchemas("schema-A");
+ Assert.assertTrue(provider.isTableSchemasDirty());
+ provider.setTableSchemas("schema-B");
+ Assert.assertEquals("schema-B", GsonUtils.GSON.fromJson(
+ provider.getPersistInfo(),
JdbcSourceOffsetProvider.class).getTableSchemas());
+ }
+
+ @Test
+ public void testSchemaFreePersistInfoRetainsOffsetAndSplitProgress() {
+ JdbcSourceOffsetProvider provider = GsonUtils.GSON.fromJson(
+ "{\"chw\":{\"db.table\":{\"db.table:0\":{\"lsn\":\"90\"}}},"
+ +
"\"bop\":{\"lsn\":\"100\"},\"ts\":\"schema-A\",\"csp\":{\"ni\":2}}",
+ JdbcSourceOffsetProvider.class);
+ String fullState = provider.getPersistInfo();
+
+ JsonObject persisted =
GsonUtils.GSON.fromJson(provider.getPersistInfoWithoutTableSchemas(),
JsonObject.class);
+
+ Assert.assertFalse(persisted.has("ts"));
+ Assert.assertEquals("100",
persisted.getAsJsonObject("bop").get("lsn").getAsString());
+ Assert.assertEquals("90",
persisted.getAsJsonObject("chw").getAsJsonObject("db.table")
+ .getAsJsonObject("db.table:0").get("lsn").getAsString());
+ Assert.assertEquals(2,
persisted.getAsJsonObject("csp").get("ni").getAsInt());
+ Assert.assertEquals(fullState, provider.getPersistInfo());
+ }
+
+ @Test
+ public void testPersistedStateMergesWithoutRuntimeInitialization() {
+ SourceOffsetProvider merger = new JdbcSourceOffsetProvider();
+ String previous = "{\"bop\":{\"lsn\":\"100\"},\"ts\":\"schema-A\"}";
+ String merged = merger.mergePersistInfo(previous,
"{\"bop\":{\"lsn\":\"200\"}}");
+ JdbcSourceOffsetProvider provider = GsonUtils.GSON.fromJson(merged,
JdbcSourceOffsetProvider.class);
+ Assert.assertEquals("schema-A", provider.getTableSchemas());
+ Assert.assertEquals("200",
provider.getBinlogOffsetPersist().get("lsn"));
+ Assert.assertNull(provider.getCurrentOffset());
+
+ String explicitNull = merger.mergePersistInfo(merged,
+ "{\"bop\":{\"lsn\":\"300\"},\"ts\":null}");
+ JdbcSourceOffsetProvider recovered =
GsonUtils.GSON.fromJson(explicitNull, JdbcSourceOffsetProvider.class);
+ Assert.assertEquals("schema-A", recovered.getTableSchemas());
+ Assert.assertEquals("300",
recovered.getBinlogOffsetPersist().get("lsn"));
+ String updated = "{\"bop\":{\"lsn\":\"400\"},\"ts\":\"schema-B\"}";
+ Assert.assertEquals(updated, merger.mergePersistInfo(explicitNull,
updated));
+ Assert.assertEquals(updated, merger.mergePersistInfo(null, updated));
+ }
+
+ @Test
+ public void testTvfPersistedStateWithoutSchemaReplacesPreviousSchema() {
+ SourceOffsetProvider provider = new JdbcTvfSourceOffsetProvider();
+ String previous = "{\"bop\":{\"lsn\":\"100\"},\"ts\":\"schema-A\"}";
+ for (String incoming : new String[] {
+ "{\"bop\":{\"lsn\":\"200\"}}",
"{\"bop\":{\"lsn\":\"200\"},\"ts\":null}"}) {
+ JdbcSourceOffsetProvider recovered = GsonUtils.GSON.fromJson(
+ provider.mergePersistInfo(previous, incoming),
JdbcSourceOffsetProvider.class);
+ Assert.assertNull(recovered.getTableSchemas());
+ Assert.assertEquals("200",
recovered.getBinlogOffsetPersist().get("lsn"));
+ }
+ }
+
@Test
public void testEndOffsetAdvancesWhenCurrentOffsetIsAhead() {
assertEndOffsetAdvancesWhenCurrentOffsetIsAhead(new
TestJdbcSourceOffsetProvider(-1));
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]