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 8fd2c1bd805 [fix](streaming-job) initialize CDC reader at CREATE JOB
and propagate remote errors (#64728)
8fd2c1bd805 is described below
commit 8fd2c1bd805a49f93afc2c57a283015d7e76beb8
Author: wudi <[email protected]>
AuthorDate: Thu Jun 25 15:49:07 2026 +0800
[fix](streaming-job) initialize CDC reader at CREATE JOB and propagate
remote errors (#64728)
### What problem does this PR solve?
For JDBC (MySQL/PostgreSQL) CDC streaming jobs in `initial`/`snapshot`
mode, the remote
CDC reader — which creates the PostgreSQL replication slot and
publication — was only
initialized lazily on the first runtime split fetch. Two problems
followed:
1. **CREATE JOB succeeded even when the source side was unusable.** If
reader
initialization failed at runtime (e.g. the source user lacks privilege
to
`CREATE PUBLICATION`), the job was already created and simply stayed
paused with no
progress and no clear failure at creation time.
2. **The real remote error was masked.** The cdc client returns failures
as a
`{code, data:"<message>"}` envelope over HTTP 200. FE force-deserialized
the response
straight into the success payload type, so on failure the error string
was coerced
into e.g. `List<SnapshotSplit>`, producing a misleading type-mismatch
that hid the
original cause.
---
.../job/offset/jdbc/JdbcSourceOffsetProvider.java | 96 ++++++++--------
.../JdbcSourceOffsetProviderAsyncSplitTest.java | 9 +-
.../JdbcSourceOffsetProviderErrorHandlingTest.java | 106 +++++++++++++++++
.../org/apache/doris/cdcclient/common/Env.java | 17 ++-
.../cdcclient/controller/ClientController.java | 2 +-
.../cdcclient/service/PipelineCoordinator.java | 6 +-
.../cdcclient/source/reader/SourceReader.java | 7 ++
.../reader/postgres/PostgresSourceReader.java | 20 ++--
.../cdcclient/itcase/CdcClientReadHarness.java | 13 ++-
.../cdcclient/itcase/CdcClientWriteHarness.java | 13 ++-
...gres_job_slot_dropped_during_incremental.groovy | 128 +++++++++++++++++++++
11 files changed, 356 insertions(+), 61 deletions(-)
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 bb8e59326ef..0a9e8df0921 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
@@ -48,6 +48,7 @@ import org.apache.doris.thrift.TStatusCode;
import com.fasterxml.jackson.core.JsonProcessingException;
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.annotations.SerializedName;
@@ -283,23 +284,13 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
"Failed to get end offset from backend," +
result.getStatus().getErrorMsgs(0) + ", response: "
+ result.getResponse());
}
- String response = result.getResponse();
- try {
- ResponseBody<Map<String, String>> responseObj =
objectMapper.readValue(
- response,
- new TypeReference<ResponseBody<Map<String, String>>>()
{
- }
- );
- Map<String, String> newEndOffset = responseObj.getData();
- // null→value also counts as a change: upstream may have
advanced while fetch was blocked.
- if (endBinlogOffset == null ||
!endBinlogOffset.equals(newEndOffset)) {
- hasMoreData = true;
- }
- endBinlogOffset = newEndOffset;
- } catch (JsonProcessingException e) {
- log.warn("Failed to parse end offset response: {}", response);
- throw new JobException(response);
+ Map<String, String> newEndOffset = parseCdcResponseData(
+ result.getResponse(), new TypeReference<Map<String,
String>>() {});
+ // null→value also counts as a change: upstream may have advanced
while fetch was blocked.
+ if (endBinlogOffset == null ||
!endBinlogOffset.equals(newEndOffset)) {
+ hasMoreData = true;
}
+ endBinlogOffset = newEndOffset;
} catch (TimeoutException te) {
log.warn("cdc_client RPC timeout api=/api/fetchEndOffset jobId={}
backend={}:{} timeout_sec={}",
getJobId(), backend.getHost(), backend.getBrpcPort(),
@@ -382,18 +373,9 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
"Failed to compare offset ," +
result.getStatus().getErrorMsgs(0) + ", response: "
+ result.getResponse());
}
- String response = result.getResponse();
- try {
- ResponseBody<Integer> responseObj = objectMapper.readValue(
- response,
- new TypeReference<ResponseBody<Integer>>() {
- }
- );
- return responseObj.getData() > 0;
- } catch (JsonProcessingException e) {
- log.warn("Failed to parse compare offset response: {}",
response);
- throw new JobException("Failed to parse compare offset
response: " + response);
- }
+ Integer cmp = parseCdcResponseData(
+ result.getResponse(), new TypeReference<Integer>() {});
+ return cmp != null && cmp > 0;
} catch (TimeoutException te) {
log.warn("cdc_client RPC timeout api=/api/compareOffset jobId={}
backend={}:{} timeout_sec={}",
getJobId(), backend.getHost(), backend.getBrpcPort(),
@@ -643,21 +625,23 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
// ============ Async split progress (driven by scheduler each tick)
============
/**
- * One-time setup at CREATE.
- * - initial/snapshot mode: init split progress; scheduler will drive
advanceSplits() each tick.
- * - latest mode (and other non-splitting modes): open the remote reader
(e.g. PG slot) so the
- * binlog phase can start immediately; no snapshot splitting will happen.
+ * One-time setup at CREATE. Opens the remote reader for every mode (see
initSourceReader) so
+ * source-side problems fail fast, then:
+ * - initial/snapshot mode: also init split progress; scheduler will drive
advanceSplits() each tick.
+ * - latest mode (and other non-splitting modes): nothing further; the
binlog phase can start
+ * immediately and no snapshot splitting will happen.
*/
@Override
public void initOnCreate(List<String> syncTables) throws JobException {
- if (!checkNeedSplitChunks(sourceProperties)) {
- initSourceReader();
- return;
- }
- synchronized (splitsLock) {
- this.cachedSyncTables = syncTables;
- this.committedSplitProgress = new SplitProgress();
- this.cdcSplitProgress = new SplitProgress();
+ // Open the reader for every mode so connectivity / auth / slot /
publication problems fail
+ // the CREATE JOB instead of surfacing later as a paused,
non-progressing job.
+ initSourceReader();
+ if (checkNeedSplitChunks(sourceProperties)) {
+ synchronized (splitsLock) {
+ this.cachedSyncTables = syncTables;
+ this.committedSplitProgress = new SplitProgress();
+ this.cdcSplitProgress = new SplitProgress();
+ }
}
}
@@ -872,10 +856,8 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
if (code != TStatusCode.OK) {
throw new JobException("fetchSplits backend error: " +
result.getStatus().getErrorMsgs(0));
}
- ResponseBody<List<SnapshotSplit>> resp = objectMapper.readValue(
- result.getResponse(),
- new TypeReference<ResponseBody<List<SnapshotSplit>>>() {});
- return resp.getData();
+ return parseCdcResponseData(
+ result.getResponse(), new
TypeReference<List<SnapshotSplit>>() {});
} catch (TimeoutException te) {
throw new JobException("fetchSplits RPC timeout: jobId=" +
getJobId() + " table=" + table);
} catch (Exception ex) {
@@ -883,6 +865,30 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
}
}
+ /**
+ * Decode a remote response envelope. A failure is returned as {@code
{code:1, data:"<message>"}}
+ * over HTTP 200, while success carries the typed payload in {@code data}.
Decode the envelope
+ * with a lenient {@link JsonNode} data field first so a failure throws
the raw response (which
+ * carries the real error in {@code data}) instead of a misleading
type-mismatch from forcing the
+ * success type onto an error string. Package-private for unit testing.
+ */
+ <T> T parseCdcResponseData(String response, TypeReference<T> dataType)
throws JobException {
+ ResponseBody<JsonNode> body;
+ try {
+ body = objectMapper.readValue(response, new
TypeReference<ResponseBody<JsonNode>>() {});
+ } catch (JsonProcessingException e) {
+ throw new JobException(response);
+ }
+ if (body.getCode() != RestApiStatusCode.OK.code) {
+ throw new JobException(response);
+ }
+ try {
+ return objectMapper.convertValue(body.getData(), dataType);
+ } catch (Exception e) {
+ throw new JobException(response);
+ }
+ }
+
protected boolean checkNeedSplitChunks(Map<String, String>
sourceProperties) {
String startMode = sourceProperties.get(DataSourceConfigKeys.OFFSET);
if (startMode == null) {
@@ -956,7 +962,7 @@ public class JdbcSourceOffsetProvider implements
SourceOffsetProvider {
* For example, PG slots need to be created first;
* otherwise, conflicts will occur in multi-backends scenarios.
*/
- private void initSourceReader() throws JobException {
+ protected void initSourceReader() throws JobException {
Backend backend = StreamingJobUtils.selectBackend(cloudCluster);
JobBaseConfig requestParams =
new JobBaseConfig(getJobId().toString(), sourceType.name(),
sourceProperties, getFrontendAddress());
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderAsyncSplitTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderAsyncSplitTest.java
index e6cd257ffee..a4a1230aada 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderAsyncSplitTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderAsyncSplitTest.java
@@ -63,13 +63,18 @@ public class JdbcSourceOffsetProviderAsyncSplitTest {
TestableProvider() {
super();
- // Default to initial mode so initOnCreate() takes the splitting
path
- // (latest mode would try to call initSourceReader against a real
backend).
+ // Default to initial mode so initOnCreate() takes the splitting
path.
this.sourceProperties.put(
org.apache.doris.job.cdc.DataSourceConfigKeys.OFFSET,
org.apache.doris.job.cdc.DataSourceConfigKeys.OFFSET_INITIAL);
}
+ // initOnCreate() now opens the remote reader for every mode; stub it
out so the unit test
+ // doesn't issue a real backend RPC.
+ @Override
+ protected void initSourceReader() {
+ }
+
@Override
protected List<SnapshotSplit> rpcFetchSplitsBatch(String table,
Object[] startVal, Integer splitId) {
rpcCalls.add(new RpcCall(table, startVal, splitId));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
new file mode 100644
index 00000000000..1da12b3c97a
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
@@ -0,0 +1,106 @@
+// 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.offset.jdbc;
+
+import org.apache.doris.job.cdc.split.SnapshotSplit;
+import org.apache.doris.job.exception.JobException;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Covers the remote-error handling added to {@link JdbcSourceOffsetProvider}:
+ * the response-envelope parse must surface the original remote error instead
of a
+ * type-mismatch, and initOnCreate must fail fast when the remote reader
cannot be opened.
+ */
+public class JdbcSourceOffsetProviderErrorHandlingTest {
+
+ @Test
+ public void testParseSuccessEnvelopeReturnsTypedPayload() throws
JobException {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ String response = "{\"code\":0,\"msg\":\"Success\","
+ + "\"data\":[{\"splitId\":\"db.t:0\",\"tableId\":\"db.t\"}]}";
+ List<SnapshotSplit> splits =
+ provider.parseCdcResponseData(response, new
TypeReference<List<SnapshotSplit>>() {});
+ Assert.assertEquals(1, splits.size());
+ Assert.assertEquals("db.t:0", splits.get(0).getSplitId());
+ Assert.assertEquals("db.t", splits.get(0).getTableId());
+ }
+
+ @Test
+ public void testParseFailureEnvelopeSurfacesOriginalError() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ String realError = "permission denied for database regression_db";
+ String response = "{\"code\":1,\"msg\":\"Internal Error\",\"data\":\""
+ realError + "\"}";
+ try {
+ provider.parseCdcResponseData(response, new
TypeReference<List<SnapshotSplit>>() {});
+ Assert.fail("a failed envelope must throw");
+ } catch (JobException e) {
+ Assert.assertTrue("the real remote error must be surfaced, got: "
+ e.getMessage(),
+ e.getMessage().contains(realError));
+ }
+ }
+
+ @Test
+ public void
testParseSuccessEnvelopeWithIncompatibleDataSurfacesRawResponse() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ // code=0 (remote thinks it succeeded) but data is a String where a
Map is expected.
+ String response =
"{\"code\":0,\"msg\":\"Success\",\"data\":\"not-a-map\"}";
+ try {
+ provider.parseCdcResponseData(response, new
TypeReference<Map<String, String>>() {});
+ Assert.fail("an incompatible success payload must throw");
+ } catch (JobException e) {
+ Assert.assertTrue("the raw response must be surfaced, got: " +
e.getMessage(),
+ e.getMessage().contains("not-a-map"));
+ }
+ }
+
+ @Test
+ public void testParseUnparseableResponseThrows() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+ String response = "<html>502 Bad Gateway</html>";
+ try {
+ provider.parseCdcResponseData(response, new
TypeReference<Integer>() {});
+ Assert.fail("an unparseable response must throw");
+ } catch (JobException e) {
+ Assert.assertTrue("the raw response must be surfaced, got: " +
e.getMessage(),
+ e.getMessage().contains("502"));
+ }
+ }
+
+ @Test
+ public void testInitOnCreateFailsFastWhenReaderInitFails() {
+ JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider() {
+ @Override
+ protected void initSourceReader() throws JobException {
+ throw new JobException("simulated reader init failure");
+ }
+ };
+ try {
+ provider.initOnCreate(Collections.singletonList("db.t"));
+ Assert.fail("CREATE JOB must fail when the remote reader cannot be
opened");
+ } catch (JobException e) {
+ Assert.assertTrue(e.getMessage().contains("simulated reader init
failure"));
+ }
+ }
+}
diff --git
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/common/Env.java
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/common/Env.java
index b39efd01324..f6d4e5d1243 100644
---
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/common/Env.java
+++
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/common/Env.java
@@ -85,12 +85,19 @@ public class Env {
}
public SourceReader getReader(JobBaseConfig jobConfig) {
+ return getReader(jobConfig, false);
+ }
+
+ // createResources=true only for CREATE (/api/initReader) and standalone
TVF; rebuild paths pass
+ // false so a dropped slot is not silently recreated.
+ public SourceReader getReader(JobBaseConfig jobConfig, boolean
createResources) {
if (jobConfig.getFrontendAddress() != null &&
!jobConfig.getFrontendAddress().isEmpty()) {
this.feMasterAddress = jobConfig.getFrontendAddress();
}
DataSource ds = resolveDataSource(jobConfig.getDataSource());
Env manager = Env.getCurrentEnv();
- return manager.getOrCreateReader(jobConfig.getJobId(), ds,
jobConfig.getConfig());
+ return manager.getOrCreateReader(
+ jobConfig.getJobId(), ds, jobConfig.getConfig(),
createResources);
}
/** Return the reader only if already created, else null (never creates
one). */
@@ -220,7 +227,10 @@ public class Env {
}
private SourceReader getOrCreateReader(
- String jobId, DataSource dataSource, Map<String, String> config) {
+ String jobId,
+ DataSource dataSource,
+ Map<String, String> config,
+ boolean createResources) {
Objects.requireNonNull(jobId, "jobId is null");
Objects.requireNonNull(dataSource, "dataSource is null");
JobContext context = jobContexts.get(jobId);
@@ -240,6 +250,9 @@ public class Env {
LOG.info("Creating new reader for job {}, dataSource {}", jobId,
dataSource);
context = new JobContext(jobId, dataSource, config);
SourceReader reader = context.initializeReader();
+ if (createResources) {
+ reader.createSourceResources(jobId, dataSource, config);
+ }
jobContexts.put(jobId, context);
return reader;
} finally {
diff --git
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/controller/ClientController.java
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/controller/ClientController.java
index b1dbe773844..cc0bac0665b 100644
---
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/controller/ClientController.java
+++
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/controller/ClientController.java
@@ -51,7 +51,7 @@ public class ClientController {
@RequestMapping(path = "/api/initReader", method = RequestMethod.POST)
public Object initSourceReader(@RequestBody JobBaseConfig jobConfig) {
try {
- SourceReader reader = Env.getCurrentEnv().getReader(jobConfig);
+ SourceReader reader = Env.getCurrentEnv().getReader(jobConfig,
true);
return RestResponse.success("Source reader initialized
successfully");
} catch (Exception ex) {
LOG.error("Failed to create reader, jobId={}",
jobConfig.getJobId(), ex);
diff --git
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
index 735b2b6b815..56d64546c69 100644
---
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
+++
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
@@ -128,7 +128,7 @@ public class PipelineCoordinator {
LOG.info("Generated meta for job {}: {}", fetchReq.getJobId(),
meta);
}
- sourceReader = Env.getCurrentEnv().getReader(fetchReq);
+ sourceReader = Env.getCurrentEnv().getReader(fetchReq,
!isLong(fetchReq.getJobId()));
readResult = sourceReader.prepareAndSubmitSplit(fetchReq);
} catch (Exception ex) {
throw new CommonException(ex);
@@ -286,7 +286,9 @@ public class PipelineCoordinator {
/** pull data from api for test */
public RecordWithMeta fetchRecords(FetchRecordRequest fetchRecordRequest)
throws Exception {
- SourceReader sourceReader =
Env.getCurrentEnv().getReader(fetchRecordRequest);
+ SourceReader sourceReader =
+ Env.getCurrentEnv()
+ .getReader(fetchRecordRequest,
!isLong(fetchRecordRequest.getJobId()));
SplitReadResult readResult =
sourceReader.prepareAndSubmitSplit(fetchRecordRequest);
return buildRecordResponse(sourceReader, fetchRecordRequest,
readResult);
}
diff --git
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/SourceReader.java
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/SourceReader.java
index c51572b377d..a924f33dec1 100644
---
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/SourceReader.java
+++
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/SourceReader.java
@@ -43,6 +43,13 @@ public interface SourceReader {
/** Initialization, called when the program starts */
void initialize(String jobId, DataSource dataSource, Map<String, String>
config);
+ /**
+ * Provision job-scoped source resources (PG slot/publication) once on
first open; rebuilds must
+ * not recreate them.
+ */
+ default void createSourceResources(
+ String jobId, DataSource dataSource, Map<String, String> config) {}
+
/** Divide the data to be read. For example: split mysql to chunks */
List<AbstractSourceSplit> getSourceSplits(FetchTableSplitsRequest config);
diff --git
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
index fe37d5c392f..20b5694fd76 100644
---
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
+++
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
@@ -103,6 +103,19 @@ public class PostgresSourceReader extends
JdbcIncrementalSourceReader {
@Override
public void initialize(String jobId, DataSource dataSource, Map<String,
String> config) {
+ super.initialize(jobId, dataSource, config);
+ // Inject PG schema refresher so the deserializer can fetch accurate
column types on DDL
+ if (serializer instanceof PostgresDebeziumJsonDeserializer) {
+ ((PostgresDebeziumJsonDeserializer) serializer)
+ .setPgSchemaRefresher(
+ tableId -> refreshSingleTableSchema(tableId,
config, jobId));
+ }
+ }
+
+ // First open only, NOT initialize: a rebuilt reader must not recreate a
dropped slot.
+ @Override
+ public void createSourceResources(
+ String jobId, DataSource dataSource, Map<String, String> config) {
PostgresSourceConfig sourceConfig = generatePostgresConfig(config,
jobId, 0);
PostgresDialect dialect = new PostgresDialect(sourceConfig);
// Doris-owned publication: pre-create it covering all include_tables
(autocreate is
@@ -118,13 +131,6 @@ public class PostgresSourceReader extends
JdbcIncrementalSourceReader {
createSlotForGlobalStreamSplit(dialect);
}
}
- super.initialize(jobId, dataSource, config);
- // Inject PG schema refresher so the deserializer can fetch accurate
column types on DDL
- if (serializer instanceof PostgresDebeziumJsonDeserializer) {
- ((PostgresDebeziumJsonDeserializer) serializer)
- .setPgSchemaRefresher(
- tableId -> refreshSingleTableSchema(tableId,
config, jobId));
- }
}
/**
diff --git
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientReadHarness.java
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientReadHarness.java
index d10aac75a33..1dd332bbc37 100644
---
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientReadHarness.java
+++
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientReadHarness.java
@@ -143,12 +143,22 @@ public final class CdcClientReadHarness implements
AutoCloseable {
return new JobBaseConfig(jobId, dataSource, config, null);
}
+ /**
+ * Open the job's reader the way FE's CREATE JOB does via {@code
/api/initReader}:
+ * createResources=true so the PG slot/publication are pre-created on
first open. Idempotent —
+ * later opens reuse the cached reader. Must run before any coordinator
read, whose numeric jobId
+ * would otherwise open the reader with createResources=false and never
provision them.
+ */
+ private SourceReader openReader() {
+ return Env.getCurrentEnv().getReader(baseConfig(), true);
+ }
+
/**
* Compute every snapshot chunk for one table by advancing the FE-style
cursor (nextSplitStart /
* nextSplitId) until the final chunk (the one whose splitEnd is null) is
reached.
*/
public List<SnapshotSplit> fetchAllSnapshotSplits(String table) {
- SourceReader reader = Env.getCurrentEnv().getReader(baseConfig());
+ SourceReader reader = openReader();
List<SnapshotSplit> all = new ArrayList<>();
Object[] nextSplitStart = null;
Integer nextSplitId = null;
@@ -263,6 +273,7 @@ public final class CdcClientReadHarness implements
AutoCloseable {
req.setMeta(meta);
req.setTaskId(taskId);
+ openReader();
StreamingResponseBody body = coordinator.fetchRecordStream(req);
ByteArrayOutputStream out = new ByteArrayOutputStream();
body.writeTo(out);
diff --git
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
index 199c1ad3a6f..c1eb902f46b 100644
---
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
+++
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
@@ -159,9 +159,19 @@ final class CdcClientWriteHarness implements AutoCloseable
{
return new JobBaseConfig(jobId, dataSource, config, null);
}
+ /**
+ * Open the job's reader the way FE's CREATE JOB does via {@code
/api/initReader}:
+ * createResources=true so the PG slot/publication are pre-created on
first open. Idempotent —
+ * later opens reuse the cached reader. Must run before any coordinator
write, whose numeric jobId
+ * would otherwise open the reader with createResources=false and never
provision them.
+ */
+ private SourceReader openReader() {
+ return Env.getCurrentEnv().getReader(baseConfig(), true);
+ }
+
/** Compute every snapshot chunk for one table (mirrors FE's
/api/fetchSplits cursor). */
List<SnapshotSplit> fetchAllSnapshotSplits(String table) {
- SourceReader reader = Env.getCurrentEnv().getReader(baseConfig());
+ SourceReader reader = openReader();
List<SnapshotSplit> all = new ArrayList<>();
Object[] nextSplitStart = null;
Integer nextSplitId = null;
@@ -263,6 +273,7 @@ final class CdcClientWriteHarness implements AutoCloseable {
}
private void runWrite(Map<String, Object> meta) throws Exception {
+ openReader();
coordinator.writeRecords(buildRequest(meta));
capture();
}
diff --git
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_slot_dropped_during_incremental.groovy
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_slot_dropped_during_incremental.groovy
index bbca6e22830..70bbd267b2b 100644
---
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_slot_dropped_during_incremental.groovy
+++
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_slot_dropped_during_incremental.groovy
@@ -170,5 +170,133 @@
suite("test_streaming_postgres_job_slot_dropped_during_incremental",
sql """DROP TABLE IF EXISTS ${pgDB}.${pgSchema}.${table1}"""
}
sql """drop table if exists ${currentDb}.${table1} force"""
+
+ // ===== Second scenario: same drop, but a Doris-owned slot (no
slot_name given) =====
+ // This is the case the fix actually guards: before it, the reader
rebuilt after the drop
+ // would silently recreate the Doris-owned slot at the latest LSN and
resume RUNNING (data
+ // loss). Now slot/publication are provisioned only at CREATE, so the
rebuild finds the slot
+ // gone and fails non-resumably.
+ def dorisJob = "test_streaming_pg_slot_dropped_doris_owned_job"
+ def dorisTable = "slot_dropped_doris_pg_tbl"
+ sql """DROP JOB IF EXISTS where jobname = '${dorisJob}'"""
+ sql """drop table if exists ${currentDb}.${dorisTable} force"""
+
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ sql """DROP TABLE IF EXISTS ${pgDB}.${pgSchema}.${dorisTable}"""
+ sql """CREATE TABLE ${pgDB}.${pgSchema}.${dorisTable} (
+ "id" int PRIMARY KEY,
+ "name" varchar(200)
+ )"""
+ }
+
+ // No slot_name/publication_name: Doris owns and auto-creates them at
CREATE (initReader).
+ sql """CREATE JOB ${dorisJob}
+ PROPERTIES ("max_interval" = "3")
+ ON STREAMING
+ FROM POSTGRES (
+ "jdbc_url" =
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}",
+ "driver_url" = "${driver_url}",
+ "driver_class" = "org.postgresql.Driver",
+ "user" = "${pgUser}",
+ "password" = "${pgPassword}",
+ "database" = "${pgDB}",
+ "schema" = "${pgSchema}",
+ "include_tables" = "${dorisTable}",
+ "offset" = "latest"
+ )
+ TO DATABASE ${currentDb} (
+ "table.create.properties.replication_num" = "1"
+ )
+ """
+
+ Awaitility.await().atMost(120, SECONDS).pollInterval(1,
SECONDS).until({
+ def st = sql """select status from jobs("type"="insert") where
Name='${dorisJob}'"""
+ st.size() == 1 && st.get(0).get(0) == "RUNNING"
+ })
+
+ // Phase 1: confirm steady-state incremental sync
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ for (int i = 1; i <= 10; i++) {
+ sql """INSERT INTO ${pgDB}.${pgSchema}.${dorisTable} VALUES
(${i}, 'name_${i}')"""
+ }
+ }
+ Awaitility.await().atMost(180, SECONDS).pollInterval(2,
SECONDS).until({
+ def cnt = sql """SELECT count(*) FROM ${currentDb}.${dorisTable}"""
+ cnt.size() == 1 && cnt.get(0).get(0) >= 10
+ })
+
+ // The Doris-owned slot is deterministic: doris_cdc_<jobId>. Derive it
from this job's Id
+ // rather than scanning LIKE 'doris_cdc_%' (which could pick another
suite's leftover or
+ // concurrent slot in the shared PG instance), then assert that exact
slot exists.
+ def dorisJobId = (sql """select Id from jobs("type"="insert") where
Name='${dorisJob}'""").get(0).get(0).toString()
+ def dorisSlot = "doris_cdc_${dorisJobId}"
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ def slotExists = sql """SELECT COUNT(1) FROM pg_replication_slots
WHERE slot_name = '${dorisSlot}'"""
+ assert slotExists[0][0] == 1 : "expected Doris-owned slot
${dorisSlot} to exist"
+ }
+ log.info("Doris-owned slot to drop: ${dorisSlot}")
+
+ // Phase 2: drop the Doris-owned slot out from under the running job.
Terminate whatever
+ // holds it, then drop; retry until gone (an auto-resume task may
briefly re-acquire it).
+ Awaitility.await().atMost(60, SECONDS).pollInterval(2, SECONDS).until({
+ boolean dropped = false
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ sql """SELECT pg_terminate_backend(active_pid) FROM
pg_replication_slots
+ WHERE slot_name = '${dorisSlot}' AND active_pid IS NOT
NULL"""
+ def cnt = sql """SELECT COUNT(1) FROM pg_replication_slots
WHERE slot_name = '${dorisSlot}'"""
+ if (cnt[0][0] == 0) {
+ dropped = true
+ } else {
+ try {
+ sql """SELECT
pg_drop_replication_slot('${dorisSlot}')"""
+ dropped = true
+ } catch (Exception e) {
+ log.info("slot still active, retry drop: " +
e.getMessage())
+ }
+ }
+ }
+ dropped
+ })
+
+ // Generate WAL so the rebuilt reader is forced to locate the
now-missing slot.
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ for (int i = 100; i < 110; i++) {
+ sql """INSERT INTO ${pgDB}.${pgSchema}.${dorisTable} VALUES
(${i}, 'after_drop_${i}')"""
+ }
+ }
+
+ // Phase 3: job settles in PAUSED with the slot-invalidated marker
+ Awaitility.await().atMost(240, SECONDS).pollInterval(3,
SECONDS).until({
+ def r = sql """select status, ErrorMsg from jobs("type"="insert")
where Name='${dorisJob}'"""
+ if (r.size() != 1) {
+ return false
+ }
+ def status = r.get(0).get(0)
+ def errMsg = r.get(0).get(1)
+ log.info("waiting slot-invalidated PAUSED: status=${status}
errMsg=${errMsg}")
+ status == "PAUSED" && errMsg != null &&
errMsg.toString().contains("Replication slot invalidated")
+ })
+
+ // Phase 4: must NOT auto-resume, and the dropped slot must NOT be
silently recreated
+ for (int i = 0; i < 8; i++) {
+ sleep(5000)
+ def r = sql """select status from jobs("type"="insert") where
Name='${dorisJob}'"""
+ assert r.size() == 1 && r.get(0).get(0) == "PAUSED" :
+ "job must stay PAUSED after slot invalidation, got ${r}"
+ }
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ def recreated = sql """SELECT COUNT(1) FROM pg_replication_slots
WHERE slot_name = '${dorisSlot}'"""
+ assert recreated[0][0] == 0 : "dropped Doris-owned slot must not
be silently recreated"
+ }
+
+ sql """DROP JOB IF EXISTS where jobname = '${dorisJob}'"""
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+ def slotLeft = sql """SELECT COUNT(1) FROM pg_replication_slots
WHERE slot_name = '${dorisSlot}'"""
+ if (slotLeft[0][0] != 0) {
+ sql """SELECT pg_drop_replication_slot('${dorisSlot}')"""
+ }
+ sql """DROP TABLE IF EXISTS ${pgDB}.${pgSchema}.${dorisTable}"""
+ }
+ sql """drop table if exists ${currentDb}.${dorisTable} force"""
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]