This is an automated email from the ASF dual-hosted git repository.

david-streamlio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git


The following commit(s) were added to refs/heads/master by this push:
     new adbdad6a [improve][test] Add DynamoDB source integration test (#77)
adbdad6a is described below

commit adbdad6a630a0bdc5675abe5afb1117595851946
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Fri Jul 10 07:36:17 2026 -0700

    [improve][test] Add DynamoDB source integration test (#77)
    
    The dynamodb module had only config-parsing tests; DynamoDBSource was
    never exercised. Add a LocalStack-based Testcontainers test that creates
    a stream-enabled table, seeds items, opens the source at TRIM_HORIZON,
    and asserts records flow.
    
    Records are read on a bounded worker thread with a per-record deadline,
    because the source's read() blocks and would otherwise hang the CI job
    rather than fail.
    
    Depends on the endpoint/region fix: without it the source cannot reach
    LocalStack at all.
---
 dynamodb/build.gradle.kts                          |   3 +
 .../io/dynamodb/DynamoDBSourceIntegrationTest.java | 260 +++++++++++++++++++++
 2 files changed, 263 insertions(+)

diff --git a/dynamodb/build.gradle.kts b/dynamodb/build.gradle.kts
index 980e18c0..547d827c 100644
--- a/dynamodb/build.gradle.kts
+++ b/dynamodb/build.gradle.kts
@@ -35,4 +35,7 @@ dependencies {
     implementation(libs.aws.sdk2.regions)
     implementation(libs.aws.sdk2.utils)
     implementation(libs.dynamodb.streams.kinesis.adapter)
+
+    testImplementation(libs.testcontainers.localstack)
+    testImplementation(libs.awaitility)
 }
diff --git 
a/dynamodb/src/test/java/org/apache/pulsar/io/dynamodb/DynamoDBSourceIntegrationTest.java
 
b/dynamodb/src/test/java/org/apache/pulsar/io/dynamodb/DynamoDBSourceIntegrationTest.java
new file mode 100644
index 00000000..d6600b28
--- /dev/null
+++ 
b/dynamodb/src/test/java/org/apache/pulsar/io/dynamodb/DynamoDBSourceIntegrationTest.java
@@ -0,0 +1,260 @@
+/*
+ * 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.dynamodb;
+
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.client.builder.AwsClientBuilder;
+import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
+import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
+import com.amazonaws.services.dynamodbv2.model.AttributeDefinition;
+import com.amazonaws.services.dynamodbv2.model.AttributeValue;
+import com.amazonaws.services.dynamodbv2.model.CreateTableRequest;
+import com.amazonaws.services.dynamodbv2.model.KeySchemaElement;
+import com.amazonaws.services.dynamodbv2.model.KeyType;
+import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput;
+import com.amazonaws.services.dynamodbv2.model.PutItemRequest;
+import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType;
+import com.amazonaws.services.dynamodbv2.model.StreamSpecification;
+import com.amazonaws.services.dynamodbv2.model.StreamViewType;
+import java.net.URI;
+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 java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pulsar.io.core.SourceContext;
+import org.awaitility.Awaitility;
+import org.testcontainers.containers.localstack.LocalStackContainer;
+import org.testcontainers.utility.DockerImageName;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Integration test for {@link DynamoDBSource} that exercises the full path 
from a real DynamoDB
+ * Streams-enabled table through the KCL DynamoDB Streams adapter and into the 
source's record
+ * queue. A LocalStack container provides DynamoDB, DynamoDB Streams and 
CloudWatch (the KCL uses
+ * DynamoDB for its lease table and CloudWatch for metrics).
+ */
+@Slf4j
+public class DynamoDBSourceIntegrationTest {
+
+    private static final String REGION = "us-east-1";
+    private static final String ACCESS_KEY = "access";
+    private static final String SECRET_KEY = "secret";
+    private static final String TABLE_NAME = "pulsar-dynamodb-it";
+    private static final String KEY_ATTR = "id";
+
+    // KCL against LocalStack is slow to bootstrap (lease table creation + 
shard discovery), so the
+    // first record can take well over a minute to surface. Give each read a 
generous deadline while
+    // keeping the overall test bounded so an under-delivering source fails 
promptly rather than hangs.
+    private static final int READ_TIMEOUT_SECONDS = 240;
+    private static final int EXPECTED_RECORDS = 3;
+
+    public static final LocalStackContainer LOCAL_STACK_CONTAINER =
+            new 
LocalStackContainer(DockerImageName.parse("localstack/localstack:4.0.3"))
+                    .withServices(
+                            LocalStackContainer.Service.DYNAMODB,
+                            LocalStackContainer.Service.CLOUDWATCH);
+
+    private AmazonDynamoDB dynamoDB;
+    private String streamArn;
+    private DynamoDBSource source;
+    private ExecutorService readerExecutor;
+    private Thread writerThread;
+    private final AtomicBoolean keepWriting = new AtomicBoolean(false);
+
+    @BeforeClass(alwaysRun = true)
+    public void beforeClass() {
+        LOCAL_STACK_CONTAINER.start();
+    }
+
+    @AfterClass(alwaysRun = true)
+    public void afterClass() {
+        LOCAL_STACK_CONTAINER.stop();
+    }
+
+    @BeforeMethod
+    public void setup() throws Exception {
+        readerExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "dynamodb-it-reader");
+            t.setDaemon(true);
+            return t;
+        });
+
+        dynamoDB = AmazonDynamoDBClientBuilder.standard()
+                .withEndpointConfiguration(new 
AwsClientBuilder.EndpointConfiguration(endpoint(), REGION))
+                .withCredentials(new AWSStaticCredentialsProvider(
+                        new BasicAWSCredentials(ACCESS_KEY, SECRET_KEY)))
+                .build();
+
+        dynamoDB.createTable(new CreateTableRequest()
+                .withTableName(TABLE_NAME)
+                .withAttributeDefinitions(new AttributeDefinition(KEY_ATTR, 
ScalarAttributeType.S))
+                .withKeySchema(new KeySchemaElement(KEY_ATTR, KeyType.HASH))
+                .withProvisionedThroughput(new ProvisionedThroughput(10L, 10L))
+                .withStreamSpecification(new StreamSpecification()
+                        .withStreamEnabled(true)
+                        
.withStreamViewType(StreamViewType.NEW_AND_OLD_IMAGES)));
+
+        Awaitility.await().atMost(60, TimeUnit.SECONDS).until(() ->
+                
"ACTIVE".equals(dynamoDB.describeTable(TABLE_NAME).getTable().getTableStatus()));
+
+        streamArn = 
dynamoDB.describeTable(TABLE_NAME).getTable().getLatestStreamArn();
+        assertNotNull(streamArn, "Table did not expose a stream ARN");
+        log.info("Created table {} with stream {}", TABLE_NAME, streamArn);
+
+        source = new DynamoDBSource();
+    }
+
+    @AfterMethod(alwaysRun = true)
+    public void cleanup() {
+        keepWriting.set(false);
+        if (writerThread != null) {
+            writerThread.interrupt();
+        }
+        if (readerExecutor != null) {
+            // 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 (dynamoDB != null) {
+            try {
+                dynamoDB.deleteTable(TABLE_NAME);
+            } catch (Exception e) {
+                log.warn("Failed to delete table", e);
+            }
+            dynamoDB.shutdown();
+        }
+    }
+
+    @Test(timeOut = 600_000)
+    public void testReadFromDynamoDBStream() throws Exception {
+        // Seed some items before the source starts; TRIM_HORIZON makes the 
worker replay them.
+        for (int i = 0; i < EXPECTED_RECORDS; i++) {
+            putItem("seed-" + i);
+        }
+
+        // Keep writing after open as well so the worker has a steady supply 
regardless of exactly
+        // when its lease/shard bootstrap completes.
+        startBackgroundWriter();
+
+        SourceContext sourceContext = mock(SourceContext.class);
+        source.open(buildConfig(streamArn), sourceContext);
+
+        int received = 0;
+        for (int i = 0; i < EXPECTED_RECORDS; i++) {
+            StreamsRecord record = readOne();
+            assertNotNull(record, "read() returned null");
+            assertTrue(record.getKey().isPresent(), "record had no key 
(eventID)");
+            
assertTrue(record.getProperties().containsKey(StreamsRecord.EVENT_NAME),
+                    "record missing EVENT_NAME property; got " + 
record.getProperties());
+            log.info("Received DynamoDB stream record: key={} event={}",
+                    record.getKey().orElse(null), 
record.getProperties().get(StreamsRecord.EVENT_NAME));
+            received++;
+        }
+        assertTrue(received >= EXPECTED_RECORDS,
+                "Expected at least " + EXPECTED_RECORDS + " records, got " + 
received);
+    }
+
+    /**
+     * Reads a single record on a bounded worker thread, failing (rather than 
hanging) if none
+     * arrives in time. {@link DynamoDBSource#read()} blocks forever on an 
empty queue, so it must
+     * never be called on the test thread nor inside an Awaitility assertion.
+     */
+    private StreamsRecord readOne() throws Exception {
+        Future<StreamsRecord> future = readerExecutor.submit(() -> 
source.read());
+        try {
+            return future.get(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            future.cancel(true);
+            throw new AssertionError("Timed out after " + READ_TIMEOUT_SECONDS
+                    + "s waiting for a record from the DynamoDB stream. The 
source produced no "
+                    + "record; see the KCL/worker logs above.", e);
+        }
+    }
+
+    private void startBackgroundWriter() {
+        keepWriting.set(true);
+        final AtomicInteger counter = new AtomicInteger();
+        writerThread = new Thread(() -> {
+            while (keepWriting.get()) {
+                try {
+                    putItem("live-" + counter.incrementAndGet());
+                    Thread.sleep(1000);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    return;
+                } catch (Exception e) {
+                    log.warn("Background write failed", e);
+                }
+            }
+        }, "dynamodb-it-writer");
+        writerThread.setDaemon(true);
+        writerThread.start();
+    }
+
+    private void putItem(String id) {
+        Map<String, AttributeValue> item = new HashMap<>();
+        item.put(KEY_ATTR, new AttributeValue(id));
+        item.put("payload", new AttributeValue("value-" + id));
+        dynamoDB.putItem(new PutItemRequest(TABLE_NAME, item));
+    }
+
+    private Map<String, Object> buildConfig(String arn) {
+        Map<String, Object> config = new HashMap<>();
+        // buildDynamoDBStreamsClient uses awsEndpoint; buildDynamoDBClient 
and buildCloudwatchClient
+        // gate on awsEndpoint being non-empty but then apply dynamoEndpoint / 
cloudwatchEndpoint,
+        // so all three must be provided to reach LocalStack.
+        config.put("awsEndpoint", endpoint());
+        config.put("dynamoEndpoint", endpoint());
+        config.put("cloudwatchEndpoint", endpoint());
+        config.put("awsRegion", REGION);
+        config.put("awsDynamodbStreamArn", arn);
+        config.put("awsCredentialPluginParam",
+                "{\"accessKey\":\"" + ACCESS_KEY + "\",\"secretKey\":\"" + 
SECRET_KEY + "\"}");
+        // Replay from the start of the stream so the seeded items are 
delivered.
+        config.put("initialPositionInStream", "TRIM_HORIZON");
+        // Unique application name -> unique KCL lease table per run, avoiding 
stale leases.
+        config.put("applicationName", "pulsar-dynamodb-it-" + 
System.currentTimeMillis());
+        return config;
+    }
+
+    private static String endpoint() {
+        final URI uri = 
LOCAL_STACK_CONTAINER.getEndpointOverride(LocalStackContainer.Service.DYNAMODB);
+        return uri.toString();
+    }
+}

Reply via email to