From de74ef1fc72fbb8348c043b07eeee9ae645a3d79 Mon Sep 17 00:00:00 2001 From: david-streamlio <35466513+david-streamlio@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:10:08 -0700 Subject: [PATCH] [improve][test] Add Canal source integration test Fixes #48 Stand up a full MySQL-binlog CDC pipeline with Testcontainers and assert that CanalStringSource delivers the change event. The test runs two containers on a shared network: mysql:8.0 with ROW binlog and canal/canal-server:v1.1.7 (matching the module's canal.client/canal.protocol 1.1.7). canal reaches MySQL via a network alias; the source reaches canal via the mapped 11111 port. An INSERT made after the source subscribes is read back on a bounded worker thread with a deadline, so a missing event fails fast instead of hanging CI. The test-only mysql-connector-j dependency excludes protobuf-java: it drags protobuf 4.x (for X DevAPI, unused by plain JDBC) onto the test classpath, where it outranks the platform's 3.25.5. canal's generated CanalPacket code calls GeneratedMessageV3.makeExtensionsImmutable(), which protobuf 4.x removed, so the canal client throws NoSuchMethodError on connect() under protobuf 4. With the exclusion the test runs against the same protobuf line the Pulsar runtime provides to the NAR (which still has the method), matching production. See #99 for the analysis. --- canal/build.gradle.kts | 10 + .../CanalStringSourceIntegrationTest.java | 244 ++++++++++++++++++ .../test/resources/canal/instance.properties | 56 ++++ 3 files changed, 310 insertions(+) create mode 100644 canal/src/test/java/org/apache/pulsar/io/canal/CanalStringSourceIntegrationTest.java create mode 100644 canal/src/test/resources/canal/instance.properties diff --git a/canal/build.gradle.kts b/canal/build.gradle.kts index 29397beeb3..fa96bb00b6 100644 --- a/canal/build.gradle.kts +++ b/canal/build.gradle.kts @@ -35,4 +35,14 @@ dependencies { implementation("com.alibaba.otter:canal.protocol:1.1.7") implementation("com.alibaba.otter:canal.client:1.1.7") implementation(libs.log4j.core) + + testImplementation(libs.testcontainers) + // Exclude protobuf: mysql-connector-j drags protobuf-java 4.x (X DevAPI, unused by plain JDBC) + // onto the test classpath, where it outranks the platform's 3.25.5. canal's generated + // CanalPacket code calls GeneratedMessageV3.makeExtensionsImmutable(), which protobuf 4.x + // removed, so the canal client would throw NoSuchMethodError on connect(). With the exclusion + // the test runs against the same protobuf line the Pulsar runtime provides to the NAR. + testImplementation(libs.mysql.connector.j) { + exclude(group = "com.google.protobuf", module = "protobuf-java") + } } diff --git a/canal/src/test/java/org/apache/pulsar/io/canal/CanalStringSourceIntegrationTest.java b/canal/src/test/java/org/apache/pulsar/io/canal/CanalStringSourceIntegrationTest.java new file mode 100644 index 0000000000..c124b5ec23 --- /dev/null +++ b/canal/src/test/java/org/apache/pulsar/io/canal/CanalStringSourceIntegrationTest.java @@ -0,0 +1,244 @@ +/* + * 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.pulsar.io.canal; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; +import org.apache.pulsar.functions.api.Record; +import org.apache.pulsar.io.core.SourceContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * Integration test for {@link CanalStringSource} that stands up a full MySQL-binlog CDC pipeline + * with Testcontainers: + * + *
+ * MySQL (mysql:8.0, ROW binlog) <--dump-- canal-server (canal/canal-server:v1.1.7) <--tcp-- CanalStringSource + *+ * + *
The two containers share a {@link Network}; canal reaches MySQL through the {@code mysql}
+ * network alias, and the source (running in this JVM) reaches canal through the mapped 11111 port.
+ * canal-server v1.1.7 matches the {@code canal.client}/{@code canal.protocol} 1.1.7 dependency of
+ * the module. The image does no env-var substitution, so the instance configuration is mounted from
+ * {@code src/test/resources/canal/instance.properties}.
+ */
+@Slf4j
+public class CanalStringSourceIntegrationTest {
+
+ private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.0");
+ private static final DockerImageName CANAL_IMAGE = DockerImageName.parse("canal/canal-server:v1.1.7");
+
+ private static final int MYSQL_PORT = 3306;
+ private static final int CANAL_PORT = 11111;
+
+ /**
+ * Deadline for a single bounded {@code read()}. {@code PushSource.read()} blocks until a record
+ * is pushed, so it must run on a worker thread with a deadline: an under-delivering pipeline then
+ * surfaces as a prompt, diagnosable failure instead of hanging the CI job to its own timeout.
+ */
+ private static final int READ_TIMEOUT_SECONDS = 180;
+
+ private Network network;
+ private GenericContainer> mysqlContainer;
+ private GenericContainer> canalContainer;
+ private CanalStringSource source;
+ private ExecutorService readerExecutor;
+
+ @BeforeMethod
+ public void setup() throws Exception {
+ readerExecutor = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "canal-test-reader");
+ t.setDaemon(true);
+ return t;
+ });
+
+ network = Network.newNetwork();
+
+ // GenericContainer (not MySQLContainer) so we keep full root access with the native
+ // password plugin canal 1.1.x expects for the binlog dump handshake.
+ mysqlContainer = new GenericContainer<>(MYSQL_IMAGE)
+ .withNetwork(network)
+ .withNetworkAliases("mysql")
+ .withExposedPorts(MYSQL_PORT)
+ .withEnv("MYSQL_ROOT_PASSWORD", "rootpw")
+ .withEnv("MYSQL_DATABASE", "testdb")
+ .withCommand(
+ "--default-authentication-plugin=mysql_native_password",
+ "--log-bin=mysql-bin",
+ "--binlog-format=ROW",
+ "--server-id=1")
+ .waitingFor(Wait.forLogMessage(".*ready for connections.*\\s", 2)
+ .withStartupTimeout(Duration.ofMinutes(3)));
+ mysqlContainer.start();
+
+ // Create the tracked table BEFORE canal starts, so its DDL is not part of the binlog
+ // stream canal reads; only the INSERT made after subscription should be delivered.
+ try (Connection conn = openMysql();
+ Statement stmt = conn.createStatement()) {
+ stmt.execute("CREATE TABLE testdb.products ("
+ + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, "
+ + "name VARCHAR(255) NOT NULL, "
+ + "description VARCHAR(512))");
+ }
+
+ canalContainer = new GenericContainer<>(CANAL_IMAGE)
+ .withNetwork(network)
+ .withExposedPorts(CANAL_PORT)
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource("canal/instance.properties"),
+ "/home/admin/canal-server/conf/example/instance.properties")
+ .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("canal-server"))
+ .waitingFor(Wait.forLogMessage(".*START SUCCESSFUL.*", 1)
+ .withStartupTimeout(Duration.ofMinutes(3)));
+ canalContainer.start();
+
+ source = new CanalStringSource();
+ }
+
+ @AfterMethod(alwaysRun = true)
+ public void cleanup() throws Exception {
+ if (readerExecutor != null) {
+ // shutdownNow: a reader may still be blocked in read(), which never returns null.
+ readerExecutor.shutdownNow();
+ }
+ if (source != null) {
+ try {
+ source.close();
+ } catch (Exception e) {
+ log.warn("Failed to close source", e);
+ }
+ }
+ if (canalContainer != null) {
+ canalContainer.stop();
+ }
+ if (mysqlContainer != null) {
+ mysqlContainer.stop();
+ }
+ if (network != null) {
+ network.close();
+ }
+ }
+
+ @Test(timeOut = 600_000)
+ public void testCanalCdcEvents() throws Exception {
+ SourceContext sourceContext = mock(SourceContext.class);
+ when(sourceContext.getSourceName()).thenReturn("canal-string-source-test");
+ // Sensitive fields (username/password) are looked up as secrets first; return null so the
+ // loader falls back to the plain config values below.
+ when(sourceContext.getSecret(anyString())).thenReturn(null);
+
+ Map