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 090907a6 [improve][test] Add Aerospike sink integration test (#96)
090907a6 is described below

commit 090907a66f93d8010f7050c5c4f9a6e96982375f
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Fri Jul 10 12:26:08 2026 -0700

    [improve][test] Add Aerospike sink integration test (#96)
    
    Adds the first test for the `aerospike` module, an end-to-end
    Testcontainers integration test that runs a real Aerospike Community
    Edition server, writes records through `AerospikeStringSink`, and reads
    them back with an independent `AerospikeClient` to assert the bin values
    round-trip. Fixes #47.
    
    Writing exposed a latent defect: `AerospikeAbstractSink` stored values
    via `Value.getAsBlob(...)` (Java object serialization), but in the pinned
    `aerospike-client-bc:4.5.0`, `BlobValue.estimateSize()` unconditionally
    throws "Object serializer has been disabled" -- object serialization was
    removed from that client build, so every write failed with result code
    -10. Switched the sink to `Value.get(...)`, which stores the value as a
    native, first-class Aerospike value (a string bin for the string sink)
    that is interoperable and directly queryable.
    
    Container startup is bounded with a two-minute timeout on a log-message
    wait strategy so a stalled image pull can never hang CI.
---
 aerospike/build.gradle.kts                         |   6 +
 .../pulsar/io/aerospike/AerospikeAbstractSink.java |   2 +-
 .../AerospikeStringSinkIntegrationTest.java        | 196 +++++++++++++++++++++
 3 files changed, 203 insertions(+), 1 deletion(-)

diff --git a/aerospike/build.gradle.kts b/aerospike/build.gradle.kts
index 9fb452f4..9bb17432 100644
--- a/aerospike/build.gradle.kts
+++ b/aerospike/build.gradle.kts
@@ -27,4 +27,10 @@ dependencies {
     implementation(libs.jackson.dataformat.yaml)
     implementation(libs.aerospike.client)
     implementation(libs.bcpkix.jdk18on)
+
+    testImplementation(libs.testcontainers)
+    // The Aerospike 4.5.0 client uses javax.xml.bind.DatatypeConverter 
(removed from the JDK
+    // since Java 11) when parsing partition maps. Pulsar's runtime supplies 
JAXB in production;
+    // the test classpath must provide it explicitly.
+    testRuntimeOnly(libs.jakarta.xml.bind.api)
 }
diff --git 
a/aerospike/src/main/java/org/apache/pulsar/io/aerospike/AerospikeAbstractSink.java
 
b/aerospike/src/main/java/org/apache/pulsar/io/aerospike/AerospikeAbstractSink.java
index a0cc2809..c3a37227 100644
--- 
a/aerospike/src/main/java/org/apache/pulsar/io/aerospike/AerospikeAbstractSink.java
+++ 
b/aerospike/src/main/java/org/apache/pulsar/io/aerospike/AerospikeAbstractSink.java
@@ -94,7 +94,7 @@ public abstract class AerospikeAbstractSink<K, V> implements 
Sink<byte[]> {
         KeyValue<K, V> keyValue = extractKeyValue(record);
         Key key = new Key(aerospikeSinkConfig.getKeyspace(), 
aerospikeSinkConfig.getKeySet(),
                 keyValue.getKey().toString());
-        Bin bin = new Bin(aerospikeSinkConfig.getColumnName(), 
Value.getAsBlob(keyValue.getValue()));
+        Bin bin = new Bin(aerospikeSinkConfig.getColumnName(), 
Value.get(keyValue.getValue()));
         AWriteListener listener = null;
         try {
             listener = queue.take();
diff --git 
a/aerospike/src/test/java/org/apache/pulsar/io/aerospike/AerospikeStringSinkIntegrationTest.java
 
b/aerospike/src/test/java/org/apache/pulsar/io/aerospike/AerospikeStringSinkIntegrationTest.java
new file mode 100644
index 00000000..ee0772ee
--- /dev/null
+++ 
b/aerospike/src/test/java/org/apache/pulsar/io/aerospike/AerospikeStringSinkIntegrationTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.aerospike;
+
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import com.aerospike.client.AerospikeClient;
+import com.aerospike.client.Bin;
+import com.aerospike.client.Key;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.core.SinkContext;
+import org.awaitility.Awaitility;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Integration test for {@link AerospikeStringSink}, exercised against a real 
Aerospike
+ * Community Edition server started via Testcontainers.
+ *
+ * <p>Records are pushed through the sink and then read back with an 
independent
+ * {@link AerospikeClient} to assert the bin values round-trip correctly. This 
is the first
+ * test for the {@code aerospike} module (see
+ * <a href="https://github.com/apache/pulsar-connectors/issues/47";>issue 
#47</a>).
+ *
+ * <p>The container startup is bounded with an explicit two-minute timeout on 
a log-message wait
+ * strategy so a stalled image pull can never hang the CI job.
+ */
+@Slf4j
+public class AerospikeStringSinkIntegrationTest {
+
+    // Aerospike Community Edition; the default packaged config exposes 
namespace "test" on port 3000.
+    private static final DockerImageName AEROSPIKE_IMAGE =
+            DockerImageName.parse("aerospike/aerospike-server:6.4.0.2");
+
+    private static final int AEROSPIKE_PORT = 3000;
+    private static final String NAMESPACE = "test";
+    private static final String SET = "pulsar";
+    private static final String BIN = "value";
+
+    private GenericContainer<?> aerospike;
+    private AerospikeClient client;
+    private AerospikeStringSink sink;
+
+    @BeforeClass(alwaysRun = true)
+    public void setUp() throws Exception {
+        aerospike = new GenericContainer<>(AEROSPIKE_IMAGE)
+                .withExposedPorts(AEROSPIKE_PORT)
+                // "service ready: soon there will be cake!" is logged once 
the node is accepting
+                // client requests. Bounded so a stalled pull/start cannot 
hang the job.
+                .waitingFor(Wait.forLogMessage(".*service ready.*", 1)
+                        .withStartupTimeout(Duration.ofMinutes(2)));
+        aerospike.start();
+
+        final String host = aerospike.getHost();
+        final int port = aerospike.getMappedPort(AEROSPIKE_PORT);
+
+        // Independent client used only to read results back (never to write). 
The node logs
+        // "service ready" slightly before its partitions finish rebalancing, 
so the first
+        // connection attempts can fail with "node is not yet fully 
initialized" -- retry until
+        // the client connects rather than failing fast.
+        Awaitility.await("aerospike client connected")
+                .atMost(60, TimeUnit.SECONDS)
+                .ignoreExceptions()
+                .until(() -> {
+                    if (client == null) {
+                        client = new AerospikeClient(host, port);
+                    }
+                    return client.isConnected();
+                });
+
+        // Even once connected, the namespace partition map can still be 
settling; a write issued
+        // too early fails with INVALID_NODE ("not yet fully initialized"). 
Probe with a real
+        // write+read round-trip until the namespace actually accepts 
operations, so the sink's
+        // own client (opened next) starts against a fully-initialized cluster.
+        final Key probe = new Key(NAMESPACE, SET, "__probe__");
+        Awaitility.await("aerospike namespace ready")
+                .atMost(60, TimeUnit.SECONDS)
+                .ignoreExceptions()
+                .until(() -> {
+                    client.put(null, probe, new Bin(BIN, "ready"));
+                    com.aerospike.client.Record r = client.get(null, probe);
+                    return r != null && "ready".equals(r.getString(BIN));
+                });
+        client.delete(null, probe);
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("seedHosts", host + ":" + port);
+        config.put("keyspace", NAMESPACE);
+        config.put("keySet", SET);
+        config.put("columnName", BIN);
+
+        sink = new AerospikeStringSink();
+        sink.open(config, mock(SinkContext.class));
+    }
+
+    @AfterClass(alwaysRun = true)
+    public void tearDown() throws Exception {
+        if (sink != null) {
+            try {
+                sink.close();
+            } catch (Exception e) {
+                // ignore
+            }
+        }
+        if (client != null) {
+            client.close();
+        }
+        if (aerospike != null) {
+            aerospike.stop();
+        }
+    }
+
+    @Test(timeOut = 300_000)
+    public void testWriteAndReadBack() throws Exception {
+        final int numRecords = 5;
+
+        // Push records through the sink. The sink acks a record only after 
the server confirms
+        // the async write, so we use the ack callbacks to know when the data 
is durable.
+        CompletableFuture<?>[] futures = new CompletableFuture[numRecords];
+        for (int i = 0; i < numRecords; i++) {
+            final int idx = i;
+            final String key = "key-" + i;
+            final String value = "value-" + i;
+            futures[i] = new CompletableFuture<>();
+            Record<byte[]> record = new Record<byte[]>() {
+                @Override
+                public Optional<String> getKey() {
+                    return Optional.of(key);
+                }
+
+                @Override
+                public byte[] getValue() {
+                    return value.getBytes(StandardCharsets.UTF_8);
+                }
+
+                @Override
+                public void ack() {
+                    futures[idx].complete(null);
+                }
+
+                @Override
+                public void fail() {
+                    futures[idx].completeExceptionally(new 
RuntimeException("Record " + idx + " failed"));
+                }
+            };
+            sink.write(record);
+        }
+
+        // Wait for every record to be acknowledged (i.e. written) rather than 
sleeping.
+        CompletableFuture.allOf(futures).get(60, TimeUnit.SECONDS);
+
+        // Read each record back with an independent client and assert the bin 
value round-trips.
+        for (int i = 0; i < numRecords; i++) {
+            final int idx = i;
+            final Key key = new Key(NAMESPACE, SET, "key-" + idx);
+            final String expected = "value-" + idx;
+            Awaitility.await("record key-" + idx + " visible")
+                    .atMost(30, TimeUnit.SECONDS)
+                    .untilAsserted(() -> {
+                        com.aerospike.client.Record read = client.get(null, 
key);
+                        assertNotNull(read, "record for key-" + idx + " should 
exist in Aerospike");
+                        assertEquals(read.getString(BIN), expected,
+                                "bin value for key-" + idx + " should match 
what was written");
+                    });
+        }
+    }
+}

Reply via email to