This is an automated email from the ASF dual-hosted git repository.
JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 7e63fc99245 [fix](streaming-job) Avoid redundant schema persistence in
CDC offset journals (#69129)
7e63fc99245 is described below
commit 7e63fc99245545c908a8b81ab79f5785a82eedf0
Author: wudi <[email protected]>
AuthorDate: Fri Oct 16 11:14:54 2026 +0800
[fix](streaming-job) Avoid redundant schema persistence in CDC offset
journals (#69129)
### What problem does this PR solve?
Problem Summary:
FROM-TO CDC offset commits repeatedly serialize the complete cached
table schema into scheduler-job journals, even when the CDC client
reports no schema update. A roughly 1 MiB schema can therefore add
roughly 1 MiB to every ordinary offset journal.
Keep the complete provider state in the Job and checkpoint images, but
include schemas in offset journals only when the client supplies them.
Clear the pending-schema flag after journal acknowledgement so failed
writes can retry. Merge schema-omitting records during replay, including
cold ALTER and cloud offset recovery, while retaining full-replacement
semantics for TVF providers. Reuse the existing scheduler-job operation
and freeze the journal payload before queueing it.
Compatibility: the operation and outer encoding are unchanged, but older
FE binaries replace provider state wholesale. Replaying compact journals
with an old FE during a rolling upgrade or downgrade can clear its
cached schema. This change does not add a writer compatibility switch.
---
.../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 | 434 +++++++++++++++++++++
.../jdbc/JdbcSourceOffsetProviderOffsetTest.java | 79 ++++
8 files changed, 601 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 bf93b77af1e..028fa871767 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());
@@ -1051,7 +1054,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) {
@@ -1126,6 +1131,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.
@@ -1138,8 +1147,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);
}
@@ -1148,7 +1157,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
@@ -1427,6 +1437,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();
@@ -1761,7 +1778,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 94cfd4fbbac..39c8ee71268 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;
}
}
@@ -752,7 +755,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 b3c6466e878..4530be5af9a 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
@@ -2195,6 +2195,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 0ffeb02f402..b0b30708725 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
@@ -505,6 +505,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..01af8115493
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobSchemaPersistenceTest.java
@@ -0,0 +1,434 @@
+// 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.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", "x".repeat(1024)));
+ job.commitOffset(request("200", null));
+ int smallJournalSize = journals.get(1).length;
+ job.commitOffset(request("300", "x".repeat(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(),
+ Map.of(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(),
+ Map.of(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<>(
+ Map.of(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 8adac456c01..4c6b54dc1e0 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.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -145,6 +148,82 @@ public class JdbcSourceOffsetProviderOffsetTest {
Assertions.assertTrue(provider.shouldPersistOffset(1_000L, 1_001L));
}
+ @Test
+ public void testSchemaRemainsPendingUntilJournalAcknowledgement() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ provider.setTableSchemas("schema-A");
+ Assertions.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");
+ Assertions.assertEquals("schema-A", GsonUtils.GSON.fromJson(
+ provider.getPersistInfo(),
JdbcSourceOffsetProvider.class).getTableSchemas());
+ Assertions.assertTrue(provider.isTableSchemasDirty());
+
+ provider.markTableSchemasPersisted();
+ JsonObject delta =
GsonUtils.GSON.fromJson(provider.getPersistInfoWithoutTableSchemas(),
JsonObject.class);
+ Assertions.assertFalse(delta.has("ts"));
+ Assertions.assertFalse(provider.isTableSchemasDirty());
+ Assertions.assertEquals("schema-A", provider.getTableSchemas());
+
+ provider.setTableSchemas("schema-A");
+ Assertions.assertTrue(provider.isTableSchemasDirty());
+ provider.setTableSchemas("schema-B");
+ Assertions.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);
+
+ Assertions.assertFalse(persisted.has("ts"));
+ Assertions.assertEquals("100",
persisted.getAsJsonObject("bop").get("lsn").getAsString());
+ Assertions.assertEquals("90",
persisted.getAsJsonObject("chw").getAsJsonObject("db.table")
+ .getAsJsonObject("db.table:0").get("lsn").getAsString());
+ Assertions.assertEquals(2,
persisted.getAsJsonObject("csp").get("ni").getAsInt());
+ Assertions.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);
+ Assertions.assertEquals("schema-A", provider.getTableSchemas());
+ Assertions.assertEquals("200",
provider.getBinlogOffsetPersist().get("lsn"));
+ Assertions.assertNull(provider.getCurrentOffset());
+
+ String explicitNull = merger.mergePersistInfo(merged,
+ "{\"bop\":{\"lsn\":\"300\"},\"ts\":null}");
+ JdbcSourceOffsetProvider recovered =
GsonUtils.GSON.fromJson(explicitNull, JdbcSourceOffsetProvider.class);
+ Assertions.assertEquals("schema-A", recovered.getTableSchemas());
+ Assertions.assertEquals("300",
recovered.getBinlogOffsetPersist().get("lsn"));
+ String updated = "{\"bop\":{\"lsn\":\"400\"},\"ts\":\"schema-B\"}";
+ Assertions.assertEquals(updated, merger.mergePersistInfo(explicitNull,
updated));
+ Assertions.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);
+ Assertions.assertNull(recovered.getTableSchemas());
+ Assertions.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]