diff --git a/foreign/java/.gitignore b/foreign/java/.gitignore
index 2d92e82516..4ee6a28721 100644
--- a/foreign/java/.gitignore
+++ b/foreign/java/.gitignore
@@ -2,6 +2,7 @@
.project
.settings/
.gradle/
+.kotlin/
build/
out/
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/README.md b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/README.md
index 85e4712acf..3799b4cb84 100644
--- a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/README.md
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/README.md
@@ -54,6 +54,39 @@ dependencies {
}
```
+### TCP Connection
+
+The Flink source and sink use Iggy's native TCP transport. Configure both with
+the same `IggyConnectionConfig`:
+
+```java
+IggyConnectionConfig connectionConfig = IggyConnectionConfig.builder()
+ .serverAddress("localhost:8090")
+ .username("iggy")
+ .password("iggy")
+ .connectionTimeout(Duration.ofSeconds(30))
+ .requestTimeout(Duration.ofSeconds(30))
+ .maxRetries(3)
+ .retryBackoff(Duration.ofMillis(100))
+ .enableTls(false)
+ .build();
+```
+
+The server address accepts the following formats:
+
+- `host`, using the default TCP port `8090`
+- `host:port`
+- `tcp://host:port`
+- bracketed IPv6, such as `tcp://[::1]:8090`
+
+Only TCP endpoints are accepted. HTTP URLs and addresses containing user info,
+paths, queries, or fragments are rejected during client creation. When TLS is
+enabled, the connector still uses the configured TCP endpoint.
+
+The sink uses the blocking TCP client because Flink's `SinkWriter` flush API is
+synchronous. Closing the writer flushes pending messages before closing the TCP
+connection.
+
### Building from Source
```bash
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/config/TcpEndpoint.java b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/config/TcpEndpoint.java
new file mode 100644
index 0000000000..2586375c6b
--- /dev/null
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/config/TcpEndpoint.java
@@ -0,0 +1,92 @@
+/*
+ * 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.iggy.connector.config;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * Host and port of an Iggy TCP endpoint.
+ *
+ * @param host server host
+ * @param port server TCP port
+ */
+public record TcpEndpoint(String host, int port) {
+
+ public static final int DEFAULT_PORT = 8090;
+
+ /**
+ * Parses an Iggy TCP server address.
+ *
+ * @param serverAddress address in host, host:port, or tcp://host:port form
+ * @return parsed TCP endpoint
+ */
+ public static TcpEndpoint parse(String serverAddress) {
+ if (serverAddress == null || serverAddress.isBlank()) {
+ throw new IllegalArgumentException("TCP server address cannot be null or blank");
+ }
+
+ URI uri = parseUri(serverAddress);
+ validateScheme(uri);
+ String host = extractHost(uri, serverAddress);
+ validateComponents(uri, serverAddress);
+
+ int port = uri.getPort() >= 0 ? uri.getPort() : DEFAULT_PORT;
+ validatePort(port, serverAddress);
+ return new TcpEndpoint(host, port);
+ }
+
+ private static URI parseUri(String serverAddress) {
+ try {
+ return serverAddress.contains("://") ? new URI(serverAddress) : new URI("tcp://" + serverAddress);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Invalid TCP server address: " + serverAddress, e);
+ }
+ }
+
+ private static void validateScheme(URI uri) {
+ if (!"tcp".equalsIgnoreCase(uri.getScheme())) {
+ throw new IllegalArgumentException("Unsupported TCP server address scheme: " + uri.getScheme());
+ }
+ }
+
+ private static String extractHost(URI uri, String serverAddress) {
+ String host = uri.getHost();
+ if (host == null || host.isBlank()) {
+ throw new IllegalArgumentException("Cannot extract host from TCP server address: " + serverAddress);
+ }
+ return host;
+ }
+
+ private static void validateComponents(URI uri, String serverAddress) {
+ if (uri.getUserInfo() != null
+ || !uri.getPath().isEmpty()
+ || uri.getQuery() != null
+ || uri.getFragment() != null) {
+ throw new IllegalArgumentException("Invalid TCP server address: " + serverAddress);
+ }
+ }
+
+ private static void validatePort(int port, String serverAddress) {
+ if (port == 0 || port > 65535) {
+ throw new IllegalArgumentException("Invalid TCP port in server address: " + serverAddress);
+ }
+ }
+}
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySink.java b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySink.java
index b8faf5251a..7a70218eb7 100644
--- a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySink.java
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySink.java
@@ -22,32 +22,35 @@
import org.apache.flink.api.connector.sink2.Sink;
import org.apache.flink.api.connector.sink2.SinkWriter;
import org.apache.flink.api.connector.sink2.WriterInitContext;
-import org.apache.iggy.client.blocking.http.IggyHttpClient;
+import org.apache.iggy.client.blocking.tcp.IggyTcpClient;
+import org.apache.iggy.config.RetryPolicy;
import org.apache.iggy.connector.config.IggyConnectionConfig;
+import org.apache.iggy.connector.config.TcpEndpoint;
import org.apache.iggy.connector.serialization.SerializationSchema;
import java.io.IOException;
import java.io.Serializable;
-import java.net.URISyntaxException;
import java.time.Duration;
/**
* Flink Sink implementation for writing to Iggy streams.
* Implements the Flink Sink V2 API for integration with DataStream API.
*
- *
Example usage:
+ *
+ * Example usage:
+ *
*
{@code
* events.sinkTo(
- * IggySink.builder()
- * .setConnectionConfig(connectionConfig)
- * .setStreamId("my-stream")
- * .setTopicId("my-topic")
- * .setSerializer(new JsonSerializationSchema<>())
- * .setBatchSize(100)
- * .setFlushInterval(Duration.ofSeconds(5))
- * .withBalancedPartitioning()
- * .build()
- * ).name("Iggy Sink");
+ * IggySink.builder()
+ * .setConnectionConfig(connectionConfig)
+ * .setStreamId("my-stream")
+ * .setTopicId("my-topic")
+ * .setSerializer(new JsonSerializationSchema<>())
+ * .setBatchSize(100)
+ * .setFlushInterval(Duration.ofSeconds(5))
+ * .withBalancedPartitioning()
+ * .build())
+ * .name("Iggy Sink");
* }
*
* @param the type of records to write
@@ -68,12 +71,12 @@ public class IggySink implements Sink, Serializable {
* Creates a new Iggy sink.
* Use {@link #builder()} to construct instances.
*
- * @param connectionConfig the connection configuration
- * @param streamId the stream identifier
- * @param topicId the topic identifier
- * @param serializer the serialization schema
- * @param batchSize the batch size for buffering
- * @param flushInterval the maximum flush interval
+ * @param connectionConfig the connection configuration
+ * @param streamId the stream identifier
+ * @param topicId the topic identifier
+ * @param serializer the serialization schema
+ * @param batchSize the batch size for buffering
+ * @param flushInterval the maximum flush interval
* @param partitioningStrategy the partitioning strategy
*/
public IggySink(
@@ -106,46 +109,32 @@ public static IggySinkBuilder builder() {
@Override
public SinkWriter createWriter(WriterInitContext context) throws IOException {
- IggyHttpClient httpClient = createHttpClient();
+ IggyTcpClient tcpClient = createTcpClient();
return new IggySinkWriter<>(
- httpClient, streamId, topicId, serializer, batchSize, flushInterval, partitioningStrategy);
+ tcpClient, streamId, topicId, serializer, batchSize, flushInterval, partitioningStrategy);
}
/**
- * Creates an HTTP Iggy client based on connection configuration.
+ * Creates a TCP Iggy client based on connection configuration.
*
- * @return configured HTTP Iggy client
+ * @return configured TCP Iggy client
*/
- private IggyHttpClient createHttpClient() {
+ private IggyTcpClient createTcpClient() {
try {
- // Build HTTP URL from server address using URI for proper parsing
- String serverAddress = connectionConfig.getServerAddress();
-
- // Parse server address to extract host
- java.net.URI uri = serverAddress.contains("://")
- ? new java.net.URI(serverAddress)
- : new java.net.URI("tcp://" + serverAddress);
-
- String host = uri.getHost();
- if (host == null) {
- throw new IllegalArgumentException("Cannot extract host from: " + serverAddress);
- }
-
- // Build HTTP URL with port 3000 (Iggy HTTP API default port)
- String httpUrl = "http://" + host + ":3000";
-
- // Create HTTP client
- IggyHttpClient httpClient = new IggyHttpClient(httpUrl);
-
- // Login
- httpClient.users().login(connectionConfig.getUsername(), connectionConfig.getPassword());
-
- return httpClient;
-
- } catch (URISyntaxException e) {
- throw new RuntimeException("Invalid server address format: " + connectionConfig.getServerAddress(), e);
+ TcpEndpoint endpoint = TcpEndpoint.parse(connectionConfig.getServerAddress());
+
+ return IggyTcpClient.builder()
+ .host(endpoint.host())
+ .port(endpoint.port())
+ .credentials(connectionConfig.getUsername(), connectionConfig.getPassword())
+ .connectionTimeout(connectionConfig.getConnectionTimeout())
+ .requestTimeout(connectionConfig.getRequestTimeout())
+ .retryPolicy(RetryPolicy.fixedDelay(
+ connectionConfig.getMaxRetries(), connectionConfig.getRetryBackoff()))
+ .tls(connectionConfig.isEnableTls())
+ .buildAndLogin();
} catch (RuntimeException e) {
- throw new RuntimeException("Failed to create HTTP Iggy client", e);
+ throw new RuntimeException("Failed to create TCP Iggy client", e);
}
}
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySinkWriter.java b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySinkWriter.java
index 244750b17c..5a0b6238d0 100644
--- a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySinkWriter.java
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/sink/IggySinkWriter.java
@@ -21,7 +21,7 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.flink.api.connector.sink2.SinkWriter;
-import org.apache.iggy.client.blocking.http.IggyHttpClient;
+import org.apache.iggy.client.blocking.tcp.IggyTcpClient;
import org.apache.iggy.connector.error.ConnectorException;
import org.apache.iggy.connector.serialization.SerializationSchema;
import org.apache.iggy.identifier.StreamId;
@@ -49,7 +49,7 @@ public class IggySinkWriter implements SinkWriter {
private static final Logger log = LoggerFactory.getLogger(IggySinkWriter.class);
- private final IggyHttpClient httpClient;
+ private final IggyTcpClient tcpClient;
private final String streamId;
private final String topicId;
private final SerializationSchema serializer;
@@ -73,7 +73,7 @@ public enum PartitioningStrategy {
/**
* Creates a new sink writer.
*
- * @param httpClient the HTTP Iggy client
+ * @param tcpClient the TCP Iggy client
* @param streamId the stream identifier
* @param topicId the topic identifier
* @param serializer the serialization schema
@@ -82,15 +82,15 @@ public enum PartitioningStrategy {
* @param partitioningStrategy the partitioning strategy
*/
public IggySinkWriter(
- IggyHttpClient httpClient,
+ IggyTcpClient tcpClient,
String streamId,
String topicId,
SerializationSchema serializer,
int batchSize,
Duration flushInterval,
PartitioningStrategy partitioningStrategy) {
- if (httpClient == null) {
- throw new IllegalArgumentException("httpClient cannot be null");
+ if (tcpClient == null) {
+ throw new IllegalArgumentException("tcpClient cannot be null");
}
if (StringUtils.isBlank(streamId)) {
throw new IllegalArgumentException("streamId cannot be null or empty");
@@ -108,7 +108,7 @@ public IggySinkWriter(
throw new IllegalArgumentException("flushInterval must be positive");
}
- this.httpClient = httpClient;
+ this.tcpClient = tcpClient;
this.streamId = streamId;
this.topicId = topicId;
this.serializer = serializer;
@@ -158,12 +158,11 @@ public void flush(boolean endOfInput) throws IOException {
// Determine partitioning
Partitioning partitioning = determinePartitioning();
- // Send messages to Iggy via HTTP
StreamId stream = parseStreamId(streamId);
TopicId topic = parseTopicId(topicId);
log.debug("IggySinkWriter: Sending {} messages with partitioning={}", messages.size(), partitioning);
- httpClient.messages().sendMessages(stream, topic, partitioning, messages);
+ tcpClient.messages().sendMessages(stream, topic, partitioning, messages);
totalWritten += buffer.size();
log.debug("IggySinkWriter: Successfully sent {} messages. Total written: {}", buffer.size(), totalWritten);
@@ -180,9 +179,25 @@ public void flush(boolean endOfInput) throws IOException {
@Override
public void close() throws Exception {
- // Flush any remaining buffered records
- flush(true);
- // Note: HTTP client doesn't have close() method - connections managed by Java HttpClient pool
+ IOException flushException = null;
+ try {
+ flush(true);
+ } catch (IOException e) {
+ flushException = e;
+ }
+
+ try {
+ tcpClient.close();
+ } catch (RuntimeException e) {
+ if (flushException == null) {
+ throw e;
+ }
+ flushException.addSuppressed(e);
+ }
+
+ if (flushException != null) {
+ throw flushException;
+ }
}
/**
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/source/IggySource.java b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/source/IggySource.java
index 3656241258..50c25eff6b 100644
--- a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/source/IggySource.java
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/main/java/org/apache/iggy/connector/flink/source/IggySource.java
@@ -29,6 +29,7 @@
import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
import org.apache.iggy.connector.config.IggyConnectionConfig;
import org.apache.iggy.connector.config.OffsetConfig;
+import org.apache.iggy.connector.config.TcpEndpoint;
import org.apache.iggy.consumergroup.Consumer;
import java.io.Serializable;
@@ -154,23 +155,12 @@ public SimpleVersionedSerializer getEnumeratorCheckpo
*/
private AsyncIggyTcpClient createAsyncIggyClient() {
try {
- // Parse host and port from server address
- String serverAddress = connectionConfig.getServerAddress();
- String host;
- int port = 8090; // Default TCP port
-
- if (serverAddress.contains(":")) {
- String[] parts = serverAddress.split(":");
- host = parts[0];
- port = Integer.parseInt(parts[1]);
- } else {
- host = serverAddress;
- }
+ TcpEndpoint endpoint = TcpEndpoint.parse(connectionConfig.getServerAddress());
// Create async TCP client using builder pattern with auto connect and login
return AsyncIggyTcpClient.builder()
- .host(host)
- .port(port)
+ .host(endpoint.host())
+ .port(endpoint.port())
.credentials(connectionConfig.getUsername(), connectionConfig.getPassword())
.connectionPoolSize(4)
.buildAndLogin()
diff --git a/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/test/java/org/apache/iggy/connector/config/TcpEndpointTest.java b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/test/java/org/apache/iggy/connector/config/TcpEndpointTest.java
new file mode 100644
index 0000000000..785b44e4e9
--- /dev/null
+++ b/foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/src/test/java/org/apache/iggy/connector/config/TcpEndpointTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.iggy.connector.config;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class TcpEndpointTest {
+
+ @Test
+ void shouldUseDefaultPortForHost() {
+ assertThat(TcpEndpoint.parse("localhost")).isEqualTo(new TcpEndpoint("localhost", TcpEndpoint.DEFAULT_PORT));
+ }
+
+ @Test
+ void shouldUseDefaultPortForTcpUri() {
+ assertThat(TcpEndpoint.parse("tcp://iggy.example.com"))
+ .isEqualTo(new TcpEndpoint("iggy.example.com", TcpEndpoint.DEFAULT_PORT));
+ }
+
+ @Test
+ void shouldParseHostAndPort() {
+ assertThat(TcpEndpoint.parse("iggy:8091")).isEqualTo(new TcpEndpoint("iggy", 8091));
+ }
+
+ @Test
+ void shouldParseTcpUri() {
+ assertThat(TcpEndpoint.parse("tcp://iggy.example.com:8092"))
+ .isEqualTo(new TcpEndpoint("iggy.example.com", 8092));
+ }
+
+ @Test
+ void shouldParseIpv6Address() {
+ assertThat(TcpEndpoint.parse("tcp://[::1]:8093")).isEqualTo(new TcpEndpoint("[::1]", 8093));
+ }
+
+ @Test
+ void shouldParseIpv6AddressWithoutScheme() {
+ assertThat(TcpEndpoint.parse("[2001:db8::1]:8094")).isEqualTo(new TcpEndpoint("[2001:db8::1]", 8094));
+ }
+
+ @Test
+ void shouldRejectUnsupportedScheme() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("http://localhost:3000"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unsupported TCP server address scheme");
+ }
+
+ @Test
+ void shouldRejectBlankAddress() {
+ assertThatThrownBy(() -> TcpEndpoint.parse(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("cannot be null or blank");
+ }
+
+ @Test
+ void shouldRejectNullAddress() {
+ assertThatThrownBy(() -> TcpEndpoint.parse(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("cannot be null or blank");
+ }
+
+ @Test
+ void shouldRejectMissingHost() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("tcp://:8090"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Cannot extract host");
+ }
+
+ @Test
+ void shouldRejectZeroPort() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("localhost:0"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP port");
+ }
+
+ @Test
+ void shouldRejectPortAboveMaximum() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("localhost:65536"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP port");
+ }
+
+ @Test
+ void shouldRejectUserInfo() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("tcp://user@localhost:8090"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP server address");
+ }
+
+ @Test
+ void shouldRejectPath() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("tcp://localhost:8090/messages"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP server address");
+ }
+
+ @Test
+ void shouldRejectQuery() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("tcp://localhost:8090?tls=true"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP server address");
+ }
+
+ @Test
+ void shouldRejectFragment() {
+ assertThatThrownBy(() -> TcpEndpoint.parse("tcp://localhost:8090#server"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid TCP server address");
+ }
+}