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