iggy Documentation

Apache Iggy: Hyper-Efficient Message Streaming at Laser Speed

5,149 stars Rust Rust 44 files · ~126,864 tokens #apache#http#iggy#messaging#quic#rust#streaming#tcp
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy iggy on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name iggy --restart always iggy-bdd-server
Configured Services & Ports
⚙️ iggy-leader
image: iggy-bdd-server
⚙️ iggy-follower
image: iggy-bdd-server
⚙️ rust-bdd
image: build locally
⚙️ go-bdd
image: build locally
⚙️ csharp-bdd
image: build locally
⚙️ java-bdd
image: build locally
docker-compose.yml
yaml (docker-compose.yml)
# 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.

# Iggy leader + follower cluster test setup: a real 2-node VSR cluster with
# replica 0 as the initial leader.
# Activated by: ./scripts/run-bdd-tests.sh  leader_redirection
#               ./scripts/run-bdd-tests.sh  all
#
# Server constraints that shape this file:
#   - `--replica-id ` is the only CLI arg each node needs; there is no
#     `--follower`, and a new container per run makes `--fresh` redundant.
#   - the first leader creates root from IGGY_ROOT_USERNAME / IGGY_ROOT_PASSWORD
#     and journals and replicates the account.
#   - `cluster.nodes[*].ip` is parsed as a strict IpAddr (no hostnames) and
#     cluster listeners bind to that roster IP, so nodes need static IPs and
#     healthchecks must ping the roster address rather than loopback.
#   - replica-to-replica consensus requires `ports.tcp_replica`.

x-cluster-node: &cluster-node
  image: iggy-bdd-server
  platform: linux/amd64
  build:
    context: ..
    dockerfile: core/server/Dockerfile
    target: runtime-prebuilt
    args:
      PREBUILT_IGGY_SERVER: ${IGGY_SERVER_PATH:-target/debug/iggy-server}
      PREBUILT_IGGY_CLI: ${IGGY_CLI_PATH:-target/debug/iggy}
      LIBC: glibc
      PROFILE: debug
  cap_add:
    - SYS_NICE
  security_opt:
    - seccomp:unconfined
  ulimits:
    memlock:
      soft: -1
      hard: -1

x-cluster-topology: &cluster-topology
  LLVM_PROFILE_FILE: ${BDD_SERVER_PROFILE_FILE:-}
  IGGY_ROOT_USERNAME: iggy
  IGGY_ROOT_PASSWORD: iggy
  IGGY_CLUSTER_ENABLED: "true"
  IGGY_CLUSTER_NAME: test-cluster
  # Keep role-specific scenarios on view 0 when a loaded CI runner delays
  # one replica during startup.
  IGGY_CLUSTER_HEARTBEAT_TIMEOUT: "30s"
  IGGY_CLUSTER_NODES_0_NAME: leader-node
  IGGY_CLUSTER_NODES_0_IP: 172.28.0.101
  IGGY_CLUSTER_NODES_0_REPLICA_ID: "0"
  IGGY_CLUSTER_NODES_0_PORTS_TCP: "8091"
  IGGY_CLUSTER_NODES_0_PORTS_QUIC: "8081"
  IGGY_CLUSTER_NODES_0_PORTS_HTTP: "3001"
  IGGY_CLUSTER_NODES_0_PORTS_WEBSOCKET: "8071"
  IGGY_CLUSTER_NODES_0_PORTS_TCP_REPLICA: "8191"
  IGGY_CLUSTER_NODES_1_NAME: follower-node
  IGGY_CLUSTER_NODES_1_IP: 172.28.0.102
  IGGY_CLUSTER_NODES_1_REPLICA_ID: "1"
  IGGY_CLUSTER_NODES_1_PORTS_TCP: "8092"
  IGGY_CLUSTER_NODES_1_PORTS_QUIC: "8082"
  IGGY_CLUSTER_NODES_1_PORTS_HTTP: "3002"
  IGGY_CLUSTER_NODES_1_PORTS_WEBSOCKET: "8072"
  IGGY_CLUSTER_NODES_1_PORTS_TCP_REPLICA: "8192"
  # http.enabled with cluster.enabled requires a JWT key every node can
  # verify; cluster auth provides it (derived from the shared secret).
  # Enabling auth activates follower-to-primary forwarding, so the config
  # validator requires ports.http on every roster node.
  IGGY_CLUSTER_AUTH_ENABLED: "true"
  IGGY_CLUSTER_AUTH_SHARED_SECRET: "bdd-vsr-cluster-shared-secret-0123456789"

x-cluster-bdd-deps: &cluster-bdd-deps
  depends_on:
    iggy-leader:
      condition: service_healthy
    iggy-follower:
      condition: service_healthy
  environment:
    - IGGY_TCP_ADDRESS_LEADER=iggy-leader:8091
    - IGGY_TCP_ADDRESS_FOLLOWER=iggy-follower:8092

services:
  iggy-leader:
    <<: *cluster-node
    command: [ "--replica-id", "0" ]
    healthcheck:
      test: [ "CMD", "/usr/local/bin/iggy", "--tcp-server-address", "172.28.0.101:8091", "ping" ]
      interval: 5s
      timeout: 3s
      retries: 30
      start_period: 2s
    environment:
      <<: *cluster-topology
      RUST_LOG: info
      IGGY_PATH: local_data_leader
      IGGY_TCP_ADDRESS: 0.0.0.0:8091
      IGGY_HTTP_ADDRESS: 0.0.0.0:3001
      IGGY_QUIC_ADDRESS: 0.0.0.0:8081
      IGGY_WEBSOCKET_ADDRESS: 0.0.0.0:8071
    networks:
      iggy-bdd-network:
        ipv4_address: 172.28.0.101
    volumes:
      - iggy_leader_data:/app/local_data_leader
      - ../target:/profiles

  iggy-follower:
    <<: *cluster-node
    depends_on:
      iggy-leader:
        condition: service_healthy
    command: [ "--replica-id", "1" ]
    healthcheck:
      test: [ "CMD", "/usr/local/bin/iggy", "--tcp-server-address", "172.28.0.102:8092", "ping" ]
      interval: 5s
      timeout: 3s
      retries: 30
      start_period: 2s
    environment:
      <<: *cluster-topology
      RUST_LOG: info
      IGGY_PATH: local_data_follower
      IGGY_TCP_ADDRESS: 0.0.0.0:8092
      IGGY_HTTP_ADDRESS: 0.0.0.0:3002
      IGGY_QUIC_ADDRESS: 0.0.0.0:8082
      IGGY_WEBSOCKET_ADDRESS: 0.0.0.0:8072
    networks:
      iggy-bdd-network:
        ipv4_address: 172.28.0.102
    volumes:
      - iggy_follower_data:/app/local_data_follower
      - ../target:/profiles

  rust-bdd:
    <<: *cluster-bdd-deps

  go-bdd:
    <<: *cluster-bdd-deps

  csharp-bdd:
    <<: *cluster-bdd-deps

  java-bdd:
    <<: *cluster-bdd-deps

networks:
  iggy-bdd-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24

volumes:
  iggy_leader_data:
  iggy_follower_data:

Technical Documentation: apache/iggy

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

Contents: What is Apache Iggy? ·
Features ·
Architecture ·
Clustering ·
Version ·
Supported languages SDK ·
CLI ·
Web UI ·
Connectors ·
Kafka gateway ·
Model Context Protocol ·
Docker ·
Kubernetes ·
Configuration ·
Run the server ·
Try the CLI ·
Examples ·
SDK ·
Benchmarks ·
Contributing


What is Apache Iggy?

Iggy is a persistent message streaming platform written in Rust, supporting QUIC, WebSocket, TCP (custom binary specification) and HTTP (regular REST API) transport protocols, capable of processing millions of messages per second at ultra-low latency.

Iggy provides exceptionally high throughput and performance while utilizing minimal computing resources.

This is not yet another extension running on top of existing infrastructure, such as Kafka or SQL database.

Iggy is a persistent message streaming log built from the ground up using low-level I/O with thread-per-core shared nothing architecture, io_uring and compio for maximum speed and efficiency.

The name is an abbreviation for the Italian Greyhound - small yet extremely fast dogs, the best in their class. See the lovely Fabio & Cookie ❤️


Features

  • Highly performant, persistent append-only log for message streaming
  • Very high throughput for both writes and reads
  • Low latency and predictable resource usage thanks to the Rust compiled language (no GC) and io_uring.
  • User authentication and authorization with granular permissions and Personal Access Tokens (PAT)
  • Support for multiple streams, topics and partitions
  • Support for multiple transport protocols (QUIC, WebSocket, TCP, HTTP)
  • Fully operational RESTful API which can be optionally enabled
  • Available client SDK in multiple languages
  • Thread per core shared nothing design together with io_uring guarantee the best possible performance on modern Linux systems.
  • Works directly with binary data, avoiding enforced schema and serialization/deserialization overhead
  • Custom zero-copy (de)serialization, which greatly improves the performance and reduces memory usage.
  • Configurable server features (e.g. caching and transport protocols), plus per-topic segment size, durability and flush thresholds
  • Server-side storage of consumer offsets
  • Multiple ways of polling the messages:
    • By offset (using the indexes)
    • By timestamp (using the time indexes)
    • First/Last N messages
    • Next N messages for the specific consumer
  • Optional poll auto-commit; processing guarantees depend on application processing and offset-commit ordering
  • Consumer groups providing the message ordering and horizontal scaling across the connected clients
  • Message expiry with auto deletion based on the configurable retention policy
  • Additional features such as server side message deduplication
  • Multi-tenant support via abstraction of streams which group topics
  • TLS support for all transport protocols (TCP, WebSocket, QUIC, HTTPS)
  • Connectors - sinks, sources and data transformations based on the custom Rust plugins
  • Model Context Protocol - provide context to LLM with MCP server
  • Optional server-side as well as client-side data encryption using AES-256-GCM
  • Optional metadata support in the form of message headers
  • Server-side message compression is not supported yet. Topic compression values
    are reserved for future disk/network compression support; use message headers
    for manual compression today (see examples/rust/src/message-headers/message-compression).
  • Optional data backups and archiving to disk or S3 compatible cloud storage (e.g. AWS S3)
  • Prometheus metrics for the server and connectors runtime, plus OpenTelemetry logs & traces in the connectors runtime. Server OTLP export is unavailable pending runtime integration.
  • Built-in CLI to manage the streaming server installable via cargo install iggy-cli
  • Built-in benchmarking app to test the performance
  • Single binary deployment without an external broker or database; dynamically linked builds still require operating-system libraries
  • Running as a single node or as a cluster, with data replication based on Viewstamped Replication (VSR)

Architecture

This is the high-level architecture of the Iggy message streaming server, where extremely high performance and ultra low and stable tail latencies are the primary goals. The server is designed to handle high throughput and very low latency (sub-millisecond tail latencies), making it suitable for real-time applications. For more details, please refer to the documentation.


Clustering

Clustering is built into the standard iggy-server binary, with no separate build or feature flag. The same server runs as a single node or as a cluster, and cluster.enabled in the configuration chooses which. Clustering is disabled by default.

Replicas stay consistent using Viewstamped Replication (VSR), which commits on a quorum and elects a new primary when the current one fails. See the clustering documentation for configuration and deployment.


Version

The official releases follow semver (for example server-0.9.0), and the Docker images also carry the latest tag (apache/iggy:latest).

The server, SDKs and CLI are versioned independently. For example, server 0.9.0 ships with Rust SDK 0.11.0 and CLI 0.14.0. The server compatibility table lists the SDK version for each server release.

We also publish edge releases (versions with an -edge.N suffix, and apache/iggy:edge) for the SDKs and the Docker images. They track the latest changes, but are not guaranteed to be stable and are not recommended for production use.


Supported languages SDK

C++ and PHP are work in progress.


CLI

The interactive CLI is implemented under core/cli, to provide the best developer experience. This is a great addition to the Web UI, especially for all the developers who prefer using the console tools.

Iggy CLI can be installed with cargo install iggy-cli and then simply accessed by typing iggy in your terminal.

Web UI

There's a dedicated Web UI for the server, which allows managing the streams, topics, partitions, browsing the messages and so on. This is an ongoing effort to build a comprehensive dashboard for administrative purposes of the Iggy server. Check the Web UI in the /web directory. The docker image for Web UI is available, and can be fetched via docker pull apache/iggy-web-ui.


Connectors

The highly performant and modular runtime for statically typed, yet dynamically loaded connectors. Ingest the data from the external sources and push it further to the Iggy streams, or fetch the data from the Iggy streams and push it further to the external sources. Create your own Rust plugins by simply implementing either the Source or Sink trait and build custom pipelines for the data processing.

toml
## Configure a sink or source connector, depending on your needs in its own config file.
type = "sink"
key = "quickwit"
enabled = true
version = 0
name = "Quickwit sink"
path = "target/release/libiggy_connector_quickwit_sink"
plugin_config_format = "yaml"

[[streams]]
stream = "qw"
topics = ["records"]
schema = "json"
batch_length = 1000
poll_interval = "5ms"
consumer_group = "qw_sink_connector"

[transforms.add_fields]
enabled = true

[[transforms.add_fields.fields]]
key = "service_name"
value.static = "qw_connector"

[[transforms.add_fields.fields]]
key = "timestamp"
value.computed = "timestamp_millis"

[transforms.delete_fields]
enabled = true
fields = ["email", "created_at"]

Kafka gateway

The Kafka gateway is a Kafka wire protocol listener in front of Iggy, intended to let existing Kafka clients use Iggy. It is in development and not yet part of a release. See its README for the current status and how to run it.


Model Context Protocol

The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to LLMs. The Iggy MCP Server is an implementation of the MCP protocol for the message streaming infrastructure. It can be used to provide context to LLMs in real-time, allowing for more accurate and relevant responses.


Docker

The official images are on Docker Hub. Images tagged latest are built from stable releases, and edge images are built from the latest master branch. Images for the other tools, such as the connectors runtime and the MCP server, are also on Docker Hub.

The published image listens on 127.0.0.1 inside the container, so the host can't reach it by default. This command makes it reachable and sets the root credentials:

bash
docker run -d --name iggy \
  --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \
  -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
  -e IGGY_TCP_ADDRESS=0.0.0.0:8090 -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 \
  -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \
  -p 8090:8090 -p 3000:3000 \
  -v iggy:/app/local_data \
  apache/iggy

The CLI is on the image's PATH:

bash
docker exec -it iggy iggy -u iggy -p iggy stream list

The SYS_NICE capability, the seccomp setting and the unlimited locked memory are required. The Docker & Helm page explains why, and has a docker-compose.yml example and the Helm chart.

On operating systems other than Linux, Docker runs in a VM, so the container might run slower.

To build the image from source instead, run docker compose up from the root of the repository. That image listens on all interfaces and keeps its data in /local_data. The container is named iggy-server, and the CLI is at /iggy. The compose file requires IGGY_ROOT_PASSWORD from your shell (IGGY_ROOT_PASSWORD=... docker compose up) and defaults IGGY_ROOT_USERNAME to iggy.


Kubernetes

A Helm chart for the server and the Web UI is in helm/charts/iggy:

bash
helm install iggy ./helm/charts/iggy --set server.image.tag=0.9.0

See the chart README and the Docker & Helm page for its settings.


Configuration

The default configuration can be found in config.toml file in core/server directory.

The configuration file is loaded from the current working directory, but you can specify the path to the configuration file by setting IGGY_CONFIG_PATH environment variable, for example export IGGY_CONFIG_PATH=core/server/config.toml (or other command depending on OS).

When config file is not found, the default values from embedded config.toml file are used.

Topic creation accepts two independent policies: durability for message acknowledgments and consumer_offset_durability for explicit offset stores and deletes. Both default to replicated. This means VSR quorum commit without waiting for stable storage. persisted also requires recoverable stable-storage copies on the replication quorum. Both policies normally store data on disk. Poll auto-commit remains asynchronous and is not covered by the poll response's completion.

The data directory is configured with path or IGGY_PATH. The layout beneath it is streams/<stream>/topics/<topic>/partitions/, with fixed directory names.

The HTTP Iggy-Durability header reports replicated or persisted for awaited writes, and none for early dispatch acceptance.

Segment flush thresholds control scheduling, independently of acknowledgment durability.

Rust HTTP callers can use HttpClient::send_messages_with_durability to read the advertised guarantee alongside confirmations.

The CLI exposes --durability persisted and --consumer-offset-durability persisted on topic create. Select either independently. The policy names describe completion guarantees and do not prescribe an I/O syscall.

For the detailed documentation of the configuration file, please refer to the configuration section.


Run the server

Build the project (the longer compilation time is due to LTO enabled in release profile:

cargo build

Run the tests:

cargo test

Set root user credentials before the first start:

Iggy requires credentials to authenticate requests to the server.
Set the root user before starting the server.

(macOS/Linux)

bash
export IGGY_ROOT_USERNAME=iggy
export IGGY_ROOT_PASSWORD=iggy

(Windows(Powershell))

bash
$env:IGGY_ROOT_USERNAME = "iggy"
$env:IGGY_ROOT_PASSWORD = "iggy"

When root does not exist, the leader needs both variables or --with-default-root-credentials below to create it. The server never generates a root password and never writes either credential to its output or log files.

Start the server:

cargo run --bin iggy-server

All the data used by the server will be persisted under the local_data directory by default, unless specified differently in the configuration (see path in config.toml).

One can use default root credentials with optional --with-default-root-credentials.
This flag is equivalent to setting IGGY_ROOT_USERNAME=iggy and IGGY_ROOT_PASSWORD=iggy, plus
it should only be used for development and testing.

cargo run --bin iggy-server -- --with-default-root-credentials

The leader creates root once and records the username and password hash in the metadata journal. Followers apply that same operation. Restarts recover the stored account without the variables or the flag, even before the first metadata checkpoint. Supplying different credentials does not replace the stored account. A half-set pair or an out-of-range value still aborts startup. To change the root password, use the API or the CLI.

For configuration options and detailed help:

cargo run --bin iggy-server -- --help

You can also use environment variables to override any configuration setting:

  • Override TCP address
    IGGY_TCP_ADDRESS=127.0.0.1:8090 cargo run --bin iggy-server

  • Set custom data path
    IGGY_PATH=/data/iggy cargo run --bin iggy-server

  • Enable HTTP transport
    IGGY_HTTP_ENABLED=true cargo run --bin iggy-server

  • Set custom root user credentials
    IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-server

To quickly generate the sample data:

cargo run --bin data-seeder-tool


Try the CLI

The commands below use the iggy CLI from the core/cli crate, run against the server started above.

Create a stream with name dev (numerical ID will be assigned by server automatically) using default credentials and tcp transport (available transports: quic, websocket, tcp, http, default tcp):

cargo run --bin iggy -- --transport tcp --username --password stream create dev

List available streams:

cargo run --bin iggy -- --username --password stream list

Get dev stream details:

cargo run --bin iggy -- -u -p stream get dev

Create a topic named sample (numerical ID will be assigned by server automatically) for stream dev, with 2 partitions (IDs 0 and 1), no topic compression (none), and disabled message expiry (skipped optional parameter). Other compression values are reserved for future server-side support:

cargo run --bin iggy -- -u -p topic create dev sample 2 none

List available topics for stream dev:

cargo run --bin iggy -- -u -p topic list dev

Get topic details for topic sample in stream dev:

cargo run --bin iggy -- -u -p topic get dev sample

Send the first message 'hello world' to the stream dev to topic sample and partition 0:

cargo run --bin iggy -- -u -p message send --partition-id 0 dev sample "hello world"

Send a second message 'lorem ipsum' to the same stream, topic and partition:

cargo run --bin iggy -- -u -p message send --partition-id 0 dev sample "lorem ipsum"

Poll messages by a regular consumer with ID 1 from the stream dev for topic sample and partition with ID 0, starting with offset 0, messages count 2, with auto commit (storing consumer offset on server):

cargo run --bin iggy -- -u -p message poll --consumer 1 --offset 0 --message-count 2 --auto-commit dev sample 0

Finally, restart the server to see it is able to load the persisted data.

The HTTP API endpoints can be found in server.http file, which can be used with REST Client extension for VS Code.

To see the detailed logs from the CLI/server, run it with the RUST_LOG=trace environment variable.


Examples

You can find comprehensive sample applications under the examples/rust directory. These examples showcase various usage patterns of the Iggy client SDK, from basic operations to advanced multi-tenant scenarios.

For detailed information about available examples and how to run them, please see the Examples README.

There are also examples for C#, Go, Java, Node.js, PHP and Python.


SDK

Iggy comes with the Rust SDK, which is available on crates.io.

The SDK provides both, low-level client for the specific transport, which includes the message sending and polling along with all the administrative actions such as managing the streams, topics, users etc., as well as the high-level client, which abstracts the low-level details and provides the easy-to-use API for both, message producers and consumers.

You can find the more examples, including the multi-tenant one under the examples directory.

rust
// Create the Iggy client
let client = IggyClient::from_connection_string("iggy://user:secret@localhost:8090")?;

// Create a producer for the given stream and one of its topics
let mut producer = client
    .producer("dev01", "events")?
    .direct( // Use either direct (instant) or background message sending
        DirectConfig::builder()
            .batch_length(1000)
            .linger_time(IggyDuration::from_str("1ms")?)
            .build(),
    )
    .partitioning(Partitioning::balanced())
    .build();

producer.init().await?;

// Send some messages to the topic
let messages = vec![IggyMessage::from_str("Hello Apache Iggy")?];
producer.send(messages).await?;

// Create a consumer for the given stream and one of its topics
let mut consumer = client
    .consumer_group("my_app", "dev01", "events")?
    .auto_commit(AutoCommit::IntervalOrWhen(
        NonZeroIggyDuration::from_str("1s")?,
        AutoCommitWhen::ConsumingAllMessages,
    ))
    .create_consumer_group_if_not_exists()
    .auto_join_consumer_group()
    .polling_strategy(PollingStrategy::next())
    .poll_interval(IggyDuration::from_str("1ms")?)
    .batch_length(1000)
    .build();

consumer.init().await?;

// Start consuming the messages
while let Some(message) = consumer.next().await {
    // Handle the message
}

Benchmarks

Benchmarks should be the first-class citizens. We believe that performance is crucial for any system, and we strive to provide the best possible performance for our users. Please check, why we believe that the transparent
benchmarking
is so important.

We've also built the benchmarking platform where anyone can upload the benchmarks and compare the results with others. Source code for the platform is available in the core/bench/dashboard directory.

For the benchmarking purposes, we've developed the dedicated iggy-bench tool, which is a part of the iggy project. It is a command-line tool that allows you to run the variety of fully customizable benchmarks.

To benchmark the project, first build the project in release mode:

bash
cargo build --release

Start iggy-server separately, then run the benchmarking app with the desired options:

  1. Sending (writing) benchmark

    bash
    cargo run --bin iggy-bench -r -- pinned-producer tcp
  2. Polling (reading) benchmark

    bash
    cargo run --bin iggy-bench -r -- pinned-consumer tcp
  3. Parallel sending and polling benchmark

    bash
    cargo run --bin iggy-bench -r -- pinned-producer-and-consumer tcp
  4. Balanced sending to multiple partitions benchmark

    bash
    cargo run --bin iggy-bench -r -- balanced-producer tcp
  5. Consumer group polling benchmark:

    bash
    cargo run --bin iggy-bench -r -- balanced-consumer-group tcp
  6. Parallel balanced sending and polling from consumer group benchmark:

    bash
    cargo run --bin iggy-bench -r -- balanced-producer-and-consumer-group tcp
  7. End to end producing and consuming benchmark (single task produces and consumes messages in sequence):

    bash
    cargo run --bin iggy-bench -r -- end-to-end-producing-consumer tcp
  8. End to end producing and consuming through a consumer group:

    bash
    cargo run --bin iggy-bench -r -- end-to-end-producing-consumer-group tcp

The benchmark connects to a running server and creates the streams, topics, and partitions needed by the selected workload. Use iggy-bench --help and iggy-bench examples for all benchmark variants, transports, and topic-option examples. Both message and consumer-offset durability independently default to replicated.

For example, to run the benchmark for the already started server, provide the additional argument --server-address 127.0.0.1:8090.

Iggy is already capable of processing millions of messages per second at the microseconds range for p99+ latency Depending on the hardware, transport protocol (quic, websocket, tcp or http) and payload size (messages-per-batch * message-size) you might expect over 5000 MB/s (e.g. 5M of 1 KB msg/sec) throughput for writes and reads.

Please refer to the mentioned benchmarking platform where you can browse the results achieved on the different hardware configurations, using the different Iggy server versions.

Host preparation

Check io_uring access, process limits, memory headroom, CPU/NUMA placement, and sustained disk/network capacity before comparing runs. Measure host-tuning changes with the same workload and durability policies.

Use the benchmark host checklist for practical setup and repeatable measurements. The Linux tuning guide explains swappiness, huge pages, writeback, CPU placement, and networking, with commands and upstream references.


Contributing

Please see Contributing


Overview (`bdd/README.md`)

Cross-SDK BDD Testing

This directory contains cross-SDK Behavior-Driven Development (BDD) tests for Apache Iggy, designed to ensure consistency across different language SDKs.

Structure

text
/* Detailed source-code truncated for AI context efficiency. */

Usage

Quick Start

bash
# Usage: ../scripts/run-bdd-tests.sh [--coverage] <sdk> [feature]
#   sdk:     rust | python | php | go | go-race | node | csharp | java | all | clean  (default: all)
#   feature: basic_messaging | leader_redirection | all  (default: all)

# Run all features for all SDKs
../scripts/run-bdd-tests.sh all

# Run specific SDK tests (all features)
../scripts/run-bdd-tests.sh rust
../scripts/run-bdd-tests.sh python
../scripts/run-bdd-tests.sh go
../scripts/run-bdd-tests.sh node
../scripts/run-bdd-tests.sh csharp
../scripts/run-bdd-tests.sh java
../scripts/run-bdd-tests.sh php

# Run only basic_messaging feature for Rust SDK
../scripts/run-bdd-tests.sh rust basic_messaging

# Run only leader_redirection
../scripts/run-bdd-tests.sh all leader_redirection

# Every suite runs against iggy-server. Build the binaries first:
#   cargo build --bin iggy-server --bin iggy

# Clean up Docker resources
../scripts/run-bdd-tests.sh clean

Requirements

  • Docker and Docker Compose
  • The tests build the latest Iggy server from source in debug mode for faster compilation

How it Works

  1. Server Containers: docker-compose.server.yml runs a single Iggy server; docker-compose.cluster.yml adds a leader + follower pair for cluster scenarios
  2. SDK Containers: Each SDK has its own container with the appropriate runtime and dependencies
  3. Shared Features: All SDKs test against the same .feature files for consistency
  4. Health Checks: Containers wait for the server to be healthy before running tests

Adding New SDKs

To add a new SDK (e.g., Node.js):

  1. Create node/ directory
  2. Add node/Dockerfile with appropriate runtime and dependencies
  3. Create node/tests/ directory with BDD implementation
  4. Add node-bdd service to docker-compose.yml
  5. Update ../scripts/run-bdd-tests.sh script
  6. Update components.yml file to include the new SDK files

CI/CD Integration

GitHub Actions workflow: ci-test-bdd.yml

Development

For Rust SDK

The Rust implementation is located in bdd/rust/ and linked via Docker volumes.

For Python SDK

The Python implementation is in bdd/python/tests/ and needs to be updated as the Python SDK API evolves.

For Go SDK

The Go implementation is located in bdd/go/tests/.

For Node SDK

The node.js BDD test are run by cucumber-js, bdd test code is located at foreign/node/src/bdd

For csharp SDK

The csharp implementation is located at foreign/csharp/Iggy_SDK.Tests.BDD

For Java SDK

The Java implementation is located in java/src/test/

For PHP SDK

The PHP implementation is located in bdd/php/tests/ and runs through PHPUnit.

Adding New Scenarios

Add new .feature files to the bdd/scenarios/ directory and implement the corresponding step definitions in each SDK's test directory.


Overview (`examples/README.md`)

Apache Iggy Examples

Here you can find the examples of how to use Apache Iggy with the different programming languages. Please note that the features available in each language may vary.


Overview (`gateways/README.md`)

Apache Iggy Gateways

Protocol gateways that let existing clients talk to Iggy without changing the core server wire surface.

Gateway Issue Description
kafka #3421 Kafka wire protocol TCP listener (port 9093)

Each gateway is a separate workspace crate under gateways/<name>/.


Overview (`rfcs/README.md`)

Iggy RFCs

The "RFC" (request for comments) process is intended to provide a consistent
and controlled path for changes to Iggy (such as new features) so that all
stakeholders can be confident about the direction of the project.

Many changes, including bug fixes and documentation improvements can be
implemented and reviewed via the normal GitHub pull request workflow.

Some changes though are "substantial", and we ask that these be put through a
bit of a design process and produce a consensus among the Iggy community and
the maintainers.

Table of Contents

When you need to follow this process

You need to follow this process if you intend to make "substantial" changes to
the Iggy server, the wire protocol, the SDKs, the connectors runtime, or the RFC
process itself. What constitutes a "substantial" change is evolving based on
community norms and varies depending on what part of the project you are
proposing to change, but may include the following.

  • Any change to the wire protocol or the on-disk format that is not a bugfix.
  • A new storage, replication, or consumption model, or a change to how an
    existing one behaves.
  • Removing features, including those behind a feature flag.
  • Large additions to the SDK surface that every SDK must then implement.

Some changes do not require an RFC:

  • Rephrasing, reorganizing, refactoring, or otherwise "changing shape does
    not change meaning".
  • Additions that strictly improve objective, numerical quality criteria
    (warning removal, speedup, better platform coverage, more parallelism, trap
    more errors, etc.)
  • Additions only likely to be noticed by other developers-of-iggy,
    invisible to users-of-iggy.

If you submit a pull request to implement a new feature without going through
the RFC process, it may be closed with a polite request to submit an RFC first.

Before creating an RFC

A hastily-proposed RFC can hurt its chances of acceptance. Low quality
proposals, proposals for previously-rejected features, or those that don't fit
into the near-term roadmap, may be quickly rejected, which can be demotivating
for the unprepared contributor. Laying some groundwork ahead of the RFC can
make the process smoother.

Although there is no single way to prepare for submitting an RFC, it is
generally a good idea to pursue feedback from other project developers
beforehand, to ascertain that the RFC may be desirable; having a consistent
impact on the project requires concerted effort toward consensus-building.

The most common preparations for writing and submitting an RFC include talking
the idea over on our Discord server, discussing the topic in GitHub
Discussions
, and occasionally posting "pre-RFCs" there. A pre-RFC thread is
separate from the RFC's own Discussion, which is opened later, once the text
exists and has had its initial review. You may file issues on this repo for
discussion, but these are not actively looked at by the maintainers.

As a rule of thumb, receiving encouraging feedback from long-standing project
developers, and particularly from the maintainers, is a good indication that
the RFC is worth pursuing.

What the process is

In short, to get a major feature added to Iggy, one must first get the RFC
merged into the rfcs/ directory of this repository as a markdown file. At
that point the RFC is "active" and may be implemented with the goal of eventual
inclusion into Iggy.

  • Fork the Iggy repository.
  • Copy rfcs/rfc-template.md to rfcs/rfc-0-my-feature.md (where
    "my-feature" is descriptive). Don't assign an RFC number yet; 0 marks an
    unassigned number, and one is assigned when the RFC is accepted and the file
    is renamed accordingly. See RFC numbering.
  • Fill in the RFC. Put care into the details: RFCs that do not present
    convincing motivation, demonstrate lack of understanding of the design's
    impact, or are disingenuous about the drawbacks or alternatives tend to
    be poorly-received.
  • Submit a pull request titled rfc(<scope>): <subject>, choosing the scope
    as described in the commit message section of CONTRIBUTING.md, which also
    covers a scope that matches nothing yet. The pull request carries the text.
    A maintainer gives it an initial review within a week: whether the change
    needs an RFC at all, whether the text is complete enough to debate, and
    editorial comments on the writing. If the change does not need an RFC, the
    pull request is closed and the change goes through the normal pull request
    workflow. If the text is not complete enough to debate, the maintainer
    labels the pull request S-waiting-on-author until it is.
  • Now that your RFC has an open pull request, update the "RFC PR" link at the
    top of the file to point at it.
  • A maintainer marks the RFC ready for discussion by labeling the pull request
    S-waiting-on-review. The label stays for the lifetime of the RFC, because
    the debate happens outside the pull request and the stale bot otherwise
    closes a pull request after 14 days without activity on it.
  • Once the RFC is ready for discussion, open a GitHub
    Discussion in the RFCs category titled RFC: <feature>, linking the pull
    request and quoting the summary, and update the "Discussion" link at the top
    of the file to point at it. The Discussion is where the design is debated;
    the pull request keeps editorial review.
  • Build consensus and integrate feedback. RFCs that have broad support are
    much more likely to make progress than those that don't receive any
    comments. Feel free to reach out to the maintainers in particular to get
    help identifying stakeholders and obstacles.
  • The maintainers will discuss the RFC, as much as possible in the Discussion
    itself. Offline discussion will be summarized in the Discussion.
  • RFCs rarely go through this process unchanged, especially as alternatives
    and drawbacks are shown. You can make edits, big and small, to the RFC to
    clarify or change the design, but make changes as new commits to the pull
    request, and reply in the Discussion with the commit hash and permalinks to
    the changed lines, explaining your changes. Specifically, do not squash or
    rebase commits after they are visible on the pull request.
  • At some point, a maintainer will propose a "motion for final comment period"
    (FCP) in the Discussion, along with a disposition for the RFC (merge or
    close).
    • This step is taken when enough of the tradeoffs have been discussed that
      the maintainers are in a position to make a decision. That does not require
      consensus amongst all participants in the Discussion (which is usually
      impossible). However, the argument supporting the disposition on the RFC
      needs to have already been clearly articulated, and there should not be a
      strong consensus against that position outside of the maintainers.
      Maintainers use their best judgment in taking this step, and the FCP itself
      ensures there is ample time and notification for stakeholders to push
      back if it is made prematurely.
    • For RFCs with lengthy discussion, the motion to FCP is usually preceded by
      a summary comment trying to lay out the current state of the discussion
      and major tradeoffs/points of disagreement.
    • Before actually entering FCP, the maintainers must sign off; this is often
      the point at which many of them first review the RFC in full depth. The
      maintainer who proposed the motion records the sign-offs in the
      Discussion and announces there when the FCP starts and when it ends.
  • The FCP lasts ten calendar days, so that it is open for at least 5 business
    days. The Discussion is mirrored to the dev mailing list, but replies on
    the list do not reach the Discussion, so final objections must be raised in
    the Discussion itself.
  • In most cases, the FCP period is quiet, and the RFC is either merged or
    closed. However, sometimes substantial new arguments or ideas are raised,
    the FCP is canceled, and the RFC goes back into development mode. If the
    reasoning behind the decision is not clear from the Discussion, the
    maintainers add a comment there describing it.

RFC numbering

RFC numbers are small sequential integers without zero padding, assigned when
an RFC is accepted, so the first RFC is rfc-1. When an FCP ends with a merge
disposition, the maintainer who announced its end opens the tracking issue for
the implementation and tells the author the next free number. The author then
renames rfcs/rfc-0-my-feature.md to that number, for example
rfcs/rfc-7-my-feature.md, and fills in the "Iggy Issue" link, before the
final approvals, since a push after approval dismisses it. The pull request is
then squash-merged, and the maintainer posts a summary comment in the
Discussion linking the merged RFC and the tracking issue.

Pull request numbers are not used as RFC numbers because this repository's
counter is shared with every other pull request, which would make RFC numbers
large, sparse, and meaningless as a sequence. While an RFC is under review it
is referred to by its feature name.

The RFC life-cycle

Once an RFC becomes "active" then authors may implement it and submit the
feature as a pull request to the Iggy repository. Being "active" is not a
rubber stamp, and in particular still does not mean the feature will ultimately
be merged; it does mean that in principle all the major stakeholders have
agreed to the feature and are amenable to merging it.

Every accepted RFC has an associated issue tracking its implementation in the
Iggy repository, so it can be assigned a priority via the triage process that
the maintainers use for all issues. If you are interested in working on the
implementation for an "active" RFC, but cannot determine if someone else is
already working on it, feel free to ask on that issue.

Furthermore, the fact that a given RFC has been accepted and is "active"
implies nothing about what priority is assigned to its implementation, nor does
it imply anything about whether an Iggy developer has been assigned the task of
implementing the feature. While it is not necessary that the author of the
RFC also write the implementation, it is by far the most effective way to see
an RFC through to completion: authors should not expect that other project
developers will take on responsibility for implementing their accepted feature.

Modifications to "active" RFCs can be done in follow-up pull requests. We
strive to write each RFC in a manner that it will reflect the final design of
the feature; but the nature of the process means that we cannot expect every
merged RFC to actually reflect what the end result will be at the time of the
next major release.

In general, once accepted, RFCs should not be substantially changed. Only very
minor changes should be submitted as amendments. More substantial changes
should be new RFCs, with a note added to the original RFC. Exactly what counts
as a "very minor change" is up to the maintainers to decide.

Help this is all too informal

The process is intended to be as lightweight as reasonable for the present
circumstances. As usual, we are trying to let the process be driven by
consensus and community norms, not impose more structure than necessary.

License

The contents of this directory are licensed under the
Apache License, Version 2.0,
like the rest of the repository.

This README and rfc-template.md are adapted from the
rust-lang/rfcs repository, which is
licensed under the MIT and Apache 2.0 licenses.

Contributions

Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
licensed as above, without any additional terms or conditions.


Overview (`web/README.md`)

Apache Iggy Web UI

This project hosts the web user interface for Apache Iggy. The web UI is built using SvelteKit.

Overview

The Iggy Web UI provides a user-friendly panel for managing various aspects of the Iggy platform, including streams, topics, partitions, and more.

The docker image is available, and can be fetched via docker pull apache/iggy-web-ui:edge.

Tooling

  • Node.js: use a version supported by the current frontend toolchain, ^22.17.0 || >=24.
  • Package manager: npm
  • pnpm and yarn are not part of the supported workflow for this package. CI, Docker builds, and the committed lockfile use npm.

Getting Started

  1. Run Iggy server first

    sh
    docker pull apache/iggy:edge
    sh
    docker run --security-opt seccomp=unconfined -p 3000:3000 -p 8090:8090 \
      -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
      -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 -e IGGY_TCP_ADDRESS=0.0.0.0:8090 \
      -e IGGY_NODE_ADVERTISED_ADDRESS=localhost apache/iggy:edge
  2. Clone the repository:

    sh
    git clone https://github.com/apache/iggy.git
  3. Build the project:

    sh
    cd iggy/web
    npm ci
  4. Run the project:

    sh
    npm run dev

    To expose port in local network run:

    sh
    npm run dev -- --host --port 3333

    Set PUBLIC_IGGY_API_URL in web/.env to the HTTP address reachable by your browser, for example:

    sh
    PUBLIC_IGGY_API_URL=http://127.0.0.1:3000

    This applies to both container and source-built servers. Use the host-published HTTP port for a container.

Roadmap

  • Authorization
  • Allow manual interval setting for auto-refresh
  • Introduce dark mode
  • Enable CRUD operations on Streams
  • Enable CRUD operations on Topics
  • Messages management
  • General UI improvements
  • Permission management (in progress)
  • GitHub actions CI/CD
  • Publish to Dockerhub as separate Image

Contributing

Feel free to contribute to the project. Your feedback and contributions are highly appreciated!


Overview (`.github/review-bot/README.md`)

Skill review bot

A repository committer comments /skill <name> on a pull request. A headless agent runs that skill against the pull request head, and the findings arrive as one pull request review with inline comments.

Commands

  • /skill <name> runs the skill on the default branch at .claude/skills/<name>/SKILL.md, or .agents/skills/<name>/SKILL.md.
  • /skill answers with the list of available skill names.

Any committer can write a skill and use it this way. The gate is a repository write permission, so the PR author alone cannot start a run.

The run reads the skill file, so a skill marked disable-model-invocation: true still works here. That flag keeps a skill out of an agent's own reach, which is the point of a human typing the command.

The two stages

  1. pr-skill-review-run.yml runs on issue_comment. First it parses the command and gates the author. It then makes sure that the skill exists on the default branch. After that it installs the Claude Code CLI, checks out the pull request head, and runs the agent. The agent writes findings.json.
  2. pr-skill-review-post.yml runs on workflow_run, where the token can write. It reads the trigger comment back from the API, so the run artifact cannot aim the review elsewhere. Then it anchors each finding on a changed line and posts one review.

The review body opens with the summary and a count per severity. A finding with no anchor goes into the body as text. When the head moved during the run, the whole set goes to the body. The poster answers every conclusion, a cancelled run included. It also answers a pull request that closed while the run worked, and a post that the API refused.

The agent also writes flip_author_label, which says whether the findings need the author before merge. If it is true and the review shows a critical or warning finding, the poster replaces S-waiting-on-review with S-waiting-on-author. That is the same move as /author. The label moves in that direction only. A review that asks for nothing leaves the label as it is, and /ready moves the pull request back to the review queue. A draft gets no state label.

What the agent can do

The agent reads. It cannot run a command, build the workspace or start a test. The run installs no Rust toolchain, so no build script, proc macro, cargo configuration or rustc wrapper from the pull request can execute.

  • Tools: Read, Grep, Glob, Write and Agent. The CLI runs with --restricted, which removes every command tool and confines the file tools to two directories: the checkout and the agent directory under the runner temp. Subagents get the same set.
  • Writes: one allow rule, the agent directory. --permission-prompts none denies every other write, a write into the checkout included.
  • Inputs: the diff, its stat and file list, the CI status of the commit, and comment-style.md, all in the agent directory.
  • Model: the Claude Code CLI at CLAUDE_CODE_VERSION, pointed at the DeepSeek Anthropic-compatible endpoint with the model in DEEPSEEK_MODEL. Both pins live in one job-level env block, and the posted review closes with a footer that names them.

A skill written for a terminal session still works. The prompt tells the agent which file replaces which command: the diff files replace git diff and gh pr diff, and the CI status file replaces a build or a test run. A finding that only a test can settle is dropped.

Watching a run

The agent step streams each tool call into the job log, one line per call, and each denied call as a deny: line. An in-progress run is therefore readable in the Actions UI. The raw event stream and the CLI error output sit in the review-out artifact next to findings.json.

The job log closes with the cost of the run. The line prices the token counts at DeepSeek rates and at two Claude tiers, next to the estimate the CLI itself reports.

Nothing appears on the pull request while a run works. An issue_comment run of a fork receives a read-only token, so the first workflow cannot post. The answer arrives with the review at the end.

Files

  • prompt.md - the task and the output contract for the agent.
  • comment-style.md - how each posted comment must read, wording rules included.

Both files come from the default branch, never from the pull request under review.

Secret

DEEPSEEK_AUTH_TOKEN holds the DeepSeek key. Add it as a repository secret on GitHub, or with gh secret set DEEPSEEK_AUTH_TOKEN.

Cost and duration

A run installs one npm package and reads code. Expect a few minutes of wall clock for a small skill, and up to 45 minutes for one that spawns subagents, on one hosted runner. The agent tokens come from the DeepSeek key.

Security notes

  • No pull request code runs. The agent has no shell, and nothing in the run builds, tests or installs from the checkout.
  • The DeepSeek key sits in the environment of the agent step. Without a shell the agent cannot read that environment, and /proc is outside its working directories. Keep the key scoped to this bot and renewable all the same.
  • No GitHub token reaches the agent step. The checkout sets persist-credentials: false and the step receives no token, so the agent has no way to post or to push.
  • The control file that names the pull request to answer sits outside the agent directory, so the agent cannot rewrite it. The poster reads the trigger comment, its author and its pull request from the API regardless.
  • The poster acts only on a run of the issue_comment event, which always runs the default branch copy of the run workflow. A fork pull request can add a workflow with the same name, and the poster ignores its runs.
  • .claude and .agents are deleted from the pull request checkout, and the default branch copy is restored. A pull request therefore cannot redefine the skill that reviews it. The same holds for AGENTS.md and CLAUDE.md at any depth, which the CLI loads as its own orders.
  • Project configuration files, hooks and MCP servers never load. --restricted ignores the project configuration files, and --strict-mcp-config with no MCP configuration loads no server.
  • The pull request text is data. The prompt says so, and a comment in the diff that addresses the reviewer can become a finding, never a command. That is a rule for the model, not a fence. A model that follows such a comment can still only read the tree and write its own directory.
  • The pull request can still steer the words that the model writes. The poster first deletes the invisible characters from every model string: Unicode tags, bidi controls, zero-width characters and variation selectors. Then it adds a zero-width space that stops HTML, mentions, links and math.
  • The review shows counts per severity, not a verdict from the model. The prompt keeps the verdict of a skill out of the summary. The poster also deletes a Verdict: APPROVE or Verdict: REQUEST CHANGES that opens a sentence. The verdict reaches the pull request only through flip_author_label, as the label move above. A pull request that steers the model can therefore at most keep its own label, or send itself to the author queue.
  • No cache is read or written. The run has no build, so it has no cache to poison.

Overview (`core/ai/README.md`)

Apache Iggy AI

This module contains the AI-related components of the Apache Iggy message streaming infrastructure.

Please check the MCP Server for the details about Iggy MCP (Model Context Protocol) server.


Overview (`core/bench/README.md`)

Apache Iggy Bench CLI

The interactive Bench CLI allows you to perform various benchmarking on the Apache Iggy server.

Iggy Bench CLI can be installed with cargo install iggy-bench and then simply accessed by typing iggy-bench in your terminal.

The WebSocket transport command is websocket, with ws as a shorthand.

Producer and consumer counts default to six. Pinned workloads also default to six streams. Override the counts for the workload and available CPUs.

Examples and topic options

Start Iggy before running benchmarks. The tool connects to an existing server. Run iggy-bench examples for all eight benchmark kinds, their aliases, all four transports, and topic-option combinations. Global options precede the benchmark kind, kind-specific options follow it, and the server address follows the transport.

Both --durability and --consumer-offset-durability independently default to replicated. The following runs use the same workload with different topic policies:

bash
# Both policies replicated, the default.
iggy-bench balanced-producer-and-consumer-group tcp

# Persisted message acknowledgments with replicated offsets.
iggy-bench --durability persisted balanced-producer-and-consumer-group tcp

# Replicated message acknowledgments with persisted offsets.
iggy-bench --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp

# Both policies persisted.
iggy-bench --durability persisted --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp

These options apply when the benchmark creates topics. They do not change existing topics with --reuse-streams. Both policies normally write data to disk. persisted adds a stable-storage completion requirement. Consumer workloads using poll auto-commit still receive asynchronous poll responses, so this flag does not turn their poll latency into a measurement of acknowledged offset-store latency.

--messages-required-to-save controls a topic's segment-flush cadence independently of durability. --max-topic-size and --message-expiry are kind-specific topic options. Use the selected kind's --help to inspect supported flags. OS writeback settings do not replace these policies.

Host preparation

Use a dedicated server and a separate load generator where possible. If they share a host, assign disjoint CPU sets and leave CPU capacity for kernel and network work. Match the deployment's CPU topology, storage, replication-group size, and durability policies.

Area Starting point What to check
Runtime access Permit the service to create io_uring instances. Startup diagnostics, container syscall policy, and effective process limits.
File descriptors and locked memory Size limits for connections, partitions, and runtime allocations. /proc/PID/limits, not just the shell's ulimit. Set systemd service limits explicitly.
Swap With enough RAM and disk-backed swap, compare vm.swappiness=10 against the existing value. Swap activity, memory pressure, and p99 latency. This does not disable swap.
Huge pages Start without an explicit reservation, then test a budgeted pool with a compatible allocator. Actual page size, usage, NUMA placement, and memory left for page cache.
CPU placement Use the server's [sharding] options within its allowed CPU set. Per-core load, CPU steal time, cgroup throttling, and IRQ distribution.
Writeback Start with the OS defaults. Sustained disk latency and dirty-page buildup. Large percentage limits can mask short-run bottlenecks.
Networking Start with the OS defaults. Receive drops, retransmissions, connection backlogs, and bandwidth-delay requirements before increasing limits.

Read the Linux tuning guide before reserving huge pages. vm.nr_hugepages=2048 reserves 4 GiB only with 2 MiB pages. MIMALLOC_RESERVE_HUGE_OS_PAGES=2048 requests 2048 one-GiB pages, or 2 TiB. They are different controls. The guide explains allocator configuration, THP, service limits, permissions, and reboot persistence.

Repeatable measurements

  1. Record the server and benchmark revisions, build profile, kernel, VM type, CPU allocation, allocator, memory limits, filesystem, and provisioned disk/network capacity.
  2. Keep workload shape, both durability policies, replication-group size, and topic options identical between compared runs. Use an explicit warmup and repeat runs.
  3. Observe vmstat 1, iostat -xz 1, mpstat -P ALL 1, memory/I/O pressure, and network errors. Confirm the client has spare CPU and network capacity.
  4. Run long enough to include segment flushes, checkpoints, and sustained device limits. Distinguish warm-cache tests, cold-cache tests, and storage throughput. Do not drop caches during a production workload.
  5. Change one host setting at a time, retain its previous value, and compare throughput, p50/p99/p99.9 latency, memory, and errors. Record successful changes in provisioning and recheck after reboot.

The output subcommand records results and accepts context for the run:

bash
iggy-bench --warmup-time 10s --total-data 10GiB balanced-producer tcp output --identifier baseline --remark replicated-defaults

Choose --total-data for the duration and storage behavior being tested. This example is not a universal sufficient data volume.


Overview (`core/binary_protocol/README.md`)

iggy_binary_protocol

Wire protocol types and codec for the Apache Iggy binary protocol, shared between server and SDK. This crate is an internal building block. Most users should depend on the iggy SDK crate instead.

The language-neutral wire spec for the login protocol-version handshake (packed version layout, ClientVersionInfo prefix, gate semantics, rejection frame offsets) lives in the version module docs.

License

Licensed under the Apache License, Version 2.0. See LICENSE.


Overview (`core/cli/README.md`)

Apache Iggy CLI

Interactive CLI for Apache Iggy. Manage streams, topics, partitions, users, consumer groups, and produce/consume messages from your terminal.

Installation

bash
cargo install iggy-cli

Run iggy --help for the full command tree.

License

Licensed under the Apache License, Version 2.0. See LICENSE.


Overview (`core/common/README.md`)

iggy_common

Common types and traits shared across the Apache Iggy ecosystem (server, SDK, CLI, tooling). This crate is an internal building block. Most users should depend on the iggy SDK crate instead.

License

Licensed under the Apache License, Version 2.0. See LICENSE.


Overview (`core/connectors/README.md`)

Apache Iggy Connectors

The highly performant and modular runtime for statically typed, yet dynamically loaded connectors. Ingest the data from the external sources and push it further to the Iggy streams, or fetch the data from the Iggy streams and push it further to the external sources. Create your own Rust plugins by simply implementing either the Source or Sink trait and build custom pipelines for the data processing.

The docker image is available, and can be fetched via docker pull apache/iggy-connect:edge.

Features

  • High Performance: Utilizes Rust's performance characteristics to ensure fast data ingestion and egress.
  • Low memory footprint: Designed with memory efficiency in mind, minimizing the memory footprint of the connectors.
  • Modular Design: Designed with modularity in mind, allowing for easy extension and customization.
  • Dynamic Loading: Supports dynamic loading of plugins, enabling seamless integration with various data sources and sinks.
  • Statically Typed: Ensures type safety and compile-time checks, reducing runtime errors.
  • Easy Customization: Provides a simple interface for implementing custom connectors, making it easy to create new plugins.
  • Data transformation: Supports data transformation with the help of existing functions.
  • Powerful configuration: Define your sinks, sources, and transformations in the configuration file or fetch them from a remote HTTP API.
  • Flexible configuration providers: Support for local file-based and HTTP-based configuration providers for centralized configuration management.
  • Observability: Prometheus metrics with per-stage latency histograms, plus an opt-in per-batch tracing benchmark target.
  • Structured logging: Selectable text or JSON log format via [logging] configuration.

Quick Start

Run these commands from the root of the same Iggy source checkout used for the server and plugins. This guide targets server 0.9.0, including its edge builds.

  1. Build the server, CLI, runtime and quick-start plugins:

    bash
    cargo build --release -p server -p iggy-cli -p iggy-connectors \
      -p iggy_connector_random_source -p iggy_connector_stdout_sink \
      -p iggy_connector_quickwit_sink

    For a debug build, omit --release and replace target/release with target/debug in both the commands and plugin paths. Make sure that the plugins specified in core/connectors/runtime/example_config/connectors/ directory under path are available. The configuration must be provided in toml format.
    The example directory also enables connectors for ClickHouse, Delta Lake, Apache Doris, Apache Iceberg, and InfluxDB. Without their backing services (or their compiled plugins) these are reported with the Error status, but they don't block the remaining connectors. Set enabled = false in their files to skip them entirely.

  2. Run docker compose -f examples/rust/src/sink-data-producer/docker-compose.yml up -d, which will start the Quickwit server to be used by an example sink connector. At this point, you can access the Quickwit UI at http://localhost:7280 - check this dashboard again later on, after the events index will be created.

  3. In the terminal that will run the connectors, set the runtime configuration path:

    bash
    export IGGY_CONNECTORS_CONFIG_PATH=core/connectors/runtime/example_config/config.toml
  4. Start the Iggy server in a separate terminal with credentials matching the sample connector configuration:

    bash
    IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-server --release

    With the server running, create the example streams and topics using the CLI from this checkout. An existing server must have these credentials, or you must adjust the commands and connector configuration to match it.

    bash
    target/release/iggy --username iggy --password iggy stream create example_stream
    target/release/iggy --username iggy --password iggy topic create example_stream example_topic 1 none 1d --durability persisted
    target/release/iggy --username iggy --password iggy stream create qw
    target/release/iggy --username iggy --password iggy topic create qw records 1 none 1d
  5. Execute cargo run --example sink-data-producer --release, which sends 100 batches of messages to previously created qw stream and records topic (this will be used by the Quickwit sink connector).

  6. Start the connector runtime cargo run --bin iggy-connectors --release in the terminal configured in step 3. The Quickwit sink indexes the produced records in the events index. At the same time, you should see the new messages being added to the example_stream stream and example_topic topic by the Random source connector - you can start the Iggy Web UI to browse the data. The messages will have applied the basic fields transformations.

Runtime

All the connectors are implemented as Rust libraries and can be used as a part of the connector runtime. The runtime is responsible for managing the lifecycle of the connectors and providing the necessary infrastructure for the connectors to run. For more information, please refer to the runtime documentation.

Plugin path resolution

The path field in connector configs points to the shared library (.so, .dylib, .dll). The runtime resolves it as follows:

  1. Extension — if the path has no recognized extension, the OS-native one is appended automatically (.so on Linux, .dylib on macOS, .dll on Windows).

  2. Absolute paths — used as-is.

  3. Relative paths — searched in order, returning the first match:

    • the literal relative path (from working directory)
    • directory of the runtime binary (filename only)
    • current working directory (filename only)
    • /usr/lib, /usr/lib64, /lib, /lib64, /usr/local/lib, /usr/local/lib64

Examples:

toml
# Relative — resolved against search dirs; extension appended on Linux
path = "target/release/libiggy_connector_stdout_sink"

# Absolute — used directly
path = "/opt/iggy/plugins/libiggy_connector_stdout_sink.so"

If the library is not found, the runtime logs all searched paths to help diagnose the issue.

Sink

Sinks are responsible for consuming the messages from the configured stream(s) and topic(s) and sending them further to the specified destination. For example, the Quickwit sink connector is responsible for sending the messages to the Quickwit indexer.

Please refer to the Sink documentation for the details about the configuration and the sample implementation.

When implementing Sink, make sure to use the sink_connector! macro to expose the FFI interface and allow the connector runtime to register the sink with the runtime. The macro also exports the connector's version (from Cargo.toml) which is reported in the runtime's /stats endpoint.
Each sink should have its own, custom configuration, which is passed along with the unique plugin ID via expected new() method.

Available Sinks

  • Doris Sink - loads JSON messages into Apache Doris tables via the Stream Load HTTP API
  • Elasticsearch Sink - sends messages to Elasticsearch indices
  • Iceberg Sink - writes data to Apache Iceberg tables via REST catalog
  • Meilisearch Sink - indexes messages in Meilisearch
  • PostgreSQL Sink - stores messages in PostgreSQL database tables
  • Quickwit Sink - indexes messages in Quickwit search engine
  • RabbitMQ Sink - publishes messages to RabbitMQ exchanges via AMQP 0.9.1
  • Reshift Sink - stores messages in Redshift warehouse tables via S3 as staging
  • S3 Sink - writes messages to Amazon S3 and S3-compatible stores (MinIO, R2, B2, DO Spaces)
  • Stdout Sink - prints messages to standard output (useful for debugging/development)
  • SurrealDB Sink - writes messages into SurrealDB with deterministic record IDs for idempotent replay

Source

Sources produce messages to an Iggy stream and topic. Configure one [[streams]] entry per source instance: the runtime retains only the last configured producer. For example, the Random source generates messages for that stream and topic.

Please refer to the Source documentation for the details about the configuration and the sample implementation.

Available Sources

  • Elasticsearch Source - polls documents from Elasticsearch indices
  • PostgreSQL Source - reads rows from PostgreSQL tables with multiple consumption strategies (delete after read, mark as processed, timestamp tracking)
  • Random Source - generates random test messages (useful for testing/development)

Building the connectors

New connector can be built simply by implementing either Sink or Source trait. Please check the sink or source documentation, as well as the existing examples under core/connectors/sinks and core/connectors/sources.

Transformations

Field transformations (depending on the supported payload formats) can be applied to the messages either before they are sent to the specified topic (e.g. when produced by the source connectors), or before consumed by the sink connectors. To add a transformation, implement the Transform trait in the SDK, add its TransformType variant and extend transforms::from_config. Each transform may have its own, custom configuration.

To find out more about the transforms, stream decoders or encoders, please refer to the SDK documentation.


Overview (`core/sdk/README.md`)

Apache Iggy Rust SDK

Official Rust client SDK for Apache Iggy, the persistent message streaming platform written in Rust. The SDK ships a low-level transport client (QUIC, TCP, HTTP, WebSocket) for direct command access and a high-level producer/consumer API with batching, consumer groups, and auto-commit.

Features

  • Transports: TCP (custom binary), QUIC, HTTP, WebSocket. One unified IggyClient API across all four.
  • TLS: TCP and WebSocket expose TLS connection-string options; QUIC always uses TLS; HTTP uses an HTTPS URL configured through the builder.
  • Connection strings: iggy:// (TCP default), iggy+tcp://, iggy+quic://, iggy+http://, iggy+ws://. Binary transports apply credentials on connect(); HTTP requires an explicit login. Option keys and reconnection support differ by transport.
  • Authentication: username/password and Personal Access Tokens (PAT).
  • Async, non-blocking client built on Tokio with custom zero-copy (de)serialization.
  • High-level builders on IggyClient: producer(stream, topic), consumer(name, stream, topic, partition), and consumer_group(name, stream, topic).
  • Producer modes: direct (awaited send) and background (buffered with parallel shard workers using OrderedSharding or BalancedSharding). Configurable batch length / size and linger time.
  • Partitioning: balanced, messages_key, or explicit partition_id. Custom Partitioner is pluggable.
  • Consumer: standalone or consumer-group over binary transports; HTTP supports standalone consumers only. Consumed as an async Stream. Polling strategies: next, offset, timestamp, first, last.
  • Auto-commit offset policies: Interval, When, After, IntervalOrWhen, IntervalOrAfter, or disabled.
  • Stream builder (IggyStream, IggyStreamProducer, IggyStreamConsumer) for declarative producer + consumer setup on shared or separate stream/topic.
  • Reliability: managed replica connections, heartbeat, ordered mutation lanes, explicit recovery of uncertain mutations, and offset auto-commit.
  • Message features: optional headers (HeaderKey / HeaderValue), client-side AES-256-GCM encryption (via Aes256GcmEncryptor), topic compression metadata (None and Gzip; no runtime compression yet), server-honored message expiry, and server-side deduplication.
  • Admin: stream/topic/partition CRUD, consumer-group management, server-side consumer offsets, system stats.

Installation

Run from your application crate. Use a release compatible with your server; for
unreleased changes, build the SDK and server from the same source checkout.

This checkout requires matching servers and SDKs with fresh storage. Older data
directories are refused without modification; --fresh deletes existing data
in the configured directory. See the server recovery guide.
Mixed versions and rolling upgrades are unsupported. Binary clients share one
logical session across replica links using BindSession (15) and discover
partition primaries with GetPartitionRoute (105). Commands 14, 103, 104 and 123
are retired. HTTP clients use server-side forwarding.

bash
cargo add iggy

All four transports are included; this crate declares no optional Cargo features.

The durable session API adds required session_identity and session_bind_secret
methods to VsrSessionControl. External transport implementations must provide
them, retain their logical identity and bind proof across transport reconnects,
and clear them on explicit logout. VsrSessionSealed is an implementation marker,
not a restriction on external implementations. The bind proof is a bearer
credential for internal session control and must never be logged or exposed.
refresh_session_password and refresh_session_username are removed. A
transport that signs in again applies a committed credential change where its
calls complete.

An established session has its own lifetime. Password or PAT changes and PAT
expiry do not end it. Logout, session lease expiry, or user deactivation end the
session and prevent its bind proof from restoring it.

Mutation recovery

TCP, QUIC and WebSocket serialize mutations within each partition history and
within the metadata lane. Different lanes can progress concurrently. An
IggyError::OperationUncertain(operation_id) retains the exact request for
recovery. A later mutation on the same lane returns
IggyError::LaneBlocked(operation_id) until that operation is resolved or abandoned.

Call client.resolve_operation(operation_id, deadline).await, where deadline
is a std::time::Instant. A proved success returns Ok(()); a proved committed
business rejection returns its original error. Both release the lane. Another
OperationUncertain keeps the request available for a later recovery attempt.
The client keeps the proved outcomes of its last max_pending_operations
replays, also across a session reset. So a later resolve returns the same
outcome without a request, also after a producer or a consumer of the client
resolved the operation first.
client.abandon_operation(operation_id).await releases the lane while leaving
the outcome unknown. A session reset drops retained requests. An
OperationNotRetained error does not establish the outcome. A reset or an
abandon came before a proved outcome, or the proved outcome aged out.
Recovery is unavailable over HTTP.

A committed password change or rename of the signed-in user updates the
credentials that the client signs in with again. This holds for a typed call, a
raw request and a change that resolve_operation proves. A committed rejection,
such as a wrong current password, leaves them unchanged.

The client defaults to 256 active or retained mutations and 64 MiB of frozen
request frames. Configure positive limits through PendingOperationsConfig
on the binary transport builder, or through connection-string options:

text
iggy://iggy:iggy@localhost:8090?max_pending_operations=256&max_pending_operation_bytes=67108864

Admission waits within the call deadline before allocating a request ID or
sending. A frame larger than the byte limit fails locally. Completion,
abandonment and session reset release operation capacity; bytes remain charged
while a writer still holds the frame. Replaying a retained request reuses its
reservation.

Auto-commit and Next polls share their partition's mutation barrier while the
call is active. They retain their weak acknowledgement: cancellation or failover
can leave progress unknown. Error 57 or a lost reply after send ends the call
immediately without replay; errors 58/89 still retry known non-admission.
These polls are not retained for recovery.

Quick start

Start a source server from the repository root in a separate terminal:

bash
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

Use disposable replica data with --fresh. Environment credentials override the
flag, and recovered credentials are not replaced. The sample expects iggy/iggy
and requires iggy, Tokio and futures-util in the application.

text
/* Detailed source-code truncated for AI context efficiency. */

For lower-level control over individual commands (login, stream/topic management, raw send, polling by offset or timestamp), use the transport-specific clients directly. See the examples and the Rust SDK docs.

For IggyConsumerConfig, partitions_count controls topic creation only. An ordinary consumer uses partition 0 unless the builder's partition_id or the config's with_partition_id selects another partition. Code that previously used partitions_count to select an existing partition must set partition_id explicitly. Consumer-group assignment ignores partition_id.

Versioning

Stable releases follow semver (x.y.z). Edge releases (x.y.z-edge.N) are cut from master between stable versions and may include unreleased fixes; pin to a stable version for production.

Resources

License

Licensed under the Apache License, Version 2.0. See LICENSE.


Overview (`core/server/README.md`)

Apache Iggy Server

The core server component of Apache Iggy: a persistent, append-only log for message streaming. It runs thread-per-core and shared-nothing on io_uring (through compio), and commits every write through Viewstamped Replication (VSR), so the same binary serves a standalone node and a multi-node cluster.

Clients connect over TCP (custom binary protocol), QUIC, WebSocket, or the HTTP REST API.

Running

sh
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=... cargo run --bin iggy-server --release

The leader needs IGGY_ROOT_USERNAME and IGGY_ROOT_PASSWORD to create root. For local development, --with-default-root-credentials sets both to iggy. The leader records root in the metadata journal and replicates the same username and password hash to followers. Restarts recover root without these inputs. The server never generates a root password or writes either credential value to its output or log files.

Missing or empty storage is initialized automatically. Existing incompatible
storage is refused without modification. --fresh deletes existing data in
the configured system path and must be an explicit reset, never a restart
default. A new cluster starts automatically when its metadata WAL and
superblock are absent. A node with retained metadata rejoins through recovery.
Loss of a replica's entire metadata directory is outside the automatic recovery
guarantee, including loss on every replica. Empty metadata cannot distinguish a
new cluster from a previously used one. Restore a verified consistent backup
through a validated recovery procedure before restarting erased metadata disks.

The Docker image apache/iggy:latest ships the server together with the CLI; the edge tag tracks the latest development build.

The image binds every listener to 0.0.0.0 so the container is reachable from outside it. A wildcard bind says which interfaces accept connections, not where a client reaches the server, so the address to publish in cluster metadata has to be supplied and the server refuses to start without it. On a single host with published ports that address is localhost:

sh
docker run -p 3000:3000 -p 8090:8090 \
  -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \
  -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=... apache/iggy:latest

Use the hostname or load balancer address clients actually dial when they are not on the same host. The Helm chart derives it from the Service DNS name.

To run one node of a cluster, pass its replica ID from the cluster.nodes roster:

sh
cargo run --bin iggy-server --release -- --replica-id 0

Command-line arguments include --replica-id, --fresh (-f), --with-default-root-credentials, and --list-config-env-vars. Other settings come from configuration.

Upgrading

Deploy matching server and SDK releases together. Rolling upgrades and mixed
server versions are unsupported. Partition control frames from older servers
carry incarnation zero and are rejected, so a mixed-version cluster cannot
complete partition view changes. The IGGY-BOUND-GRANTS-4 storage gate
requires fresh storage and rejects older data directories without modifying them.
Cached replies now preserve a committed-result marker, which older receipts lack.
No in-place upgrade or implicit migration is supported. A separately validated
offline migration or restore procedure would be required to reuse existing data.

The server no longer generates a root password when IGGY_ROOT_USERNAME and IGGY_ROOT_PASSWORD are unset. Supply both variables on every replica before the first cluster start so any promoted node can initialize root. Use --with-default-root-credentials only in development. A cluster node without credentials waits for root to be replicated. Once root is persisted, restarts need neither.

Releases up to 0.9 printed a generated password to stdout, and 0.9 also wrote it to the log file. Change any root password that was generated, and delete log files and container logs that still hold it. Snapshots redact that legacy log line.

All data directories from earlier storage formats must be wiped before upgrading. The storage-format check rejects them before metadata replay. There is no in-place upgrade or automatic migration.

After storage-format admission, a checkpoint above op 0 preserves initialized metadata. An empty checkpoint at op 0 does not prove that root exists. Without an initialized checkpoint, startup searches the current-format journal for root creation at op 1 because repair can write operations out of order. A journal with complete entries but no validated root creation is rejected before replay.

The error identifies the journal path and any unexpected op 1 fields. Restore a compatible data directory from this storage format, or use --fresh to delete local data. In multi-node clusters, use --fresh on at most one replica at a time while the others hold quorum.

The diagnostic snapshot (POST /snapshot, the binary GET_SNAPSHOT_FILE command, iggy snapshot) now requires the manage_servers permission. read_servers alone is rejected, because the archive carries raw server logs and host diagnostics.

All existing self-issued JWT access and refresh tokens are rejected after this
upgrade. HTTP clients and the web UI must log in again. In
[[http.jwt.trusted_issuers]], replace user_id with username, naming the
account the issuer may act as. The corresponding environment variable changes
from IGGY_HTTP_JWT_TRUSTED_ISSUERS_<n>_USER_ID to
IGGY_HTTP_JWT_TRUSTED_ISSUERS_<n>_USERNAME.

Cluster metadata includes replica_id. Java's ClusterNode record adds a leading
replicaId component, changing constructor calls and record patterns. C# callers
must initialize the required ReplicaId property. The CLI's cluster metadata list
appends a replica ID column. Swift's ClusterNode initializer now requires
replicaID. C++'s FFI ClusterNode, Rust's iggy_common::ClusterNode and
iggy_binary_protocol::responses::system::ClusterNodeResponse add the public
replica_id field; update aggregate and struct initializers.
Go, Java, Node.js and C# clients use command 105's new
partition routes and refresh on NotPrimary (89); use their matching releases.

Configuration

Settings are read from config.toml, resolved relative to the working directory. Set IGGY_CONFIG_PATH to load a different file.

Any single value can be overridden with an IGGY_-prefixed environment variable that mirrors the TOML path:

sh
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_HTTP_ENABLED=false cargo run --bin iggy-server

Cluster membership, quorum and replica addressing live under [cluster].

Protocol 0.11.1 uses client-owned registration proofs and BindSession (15) to
share a logical session across connections. Commands 14, 103, 104 and 123 are
retired. Partition routing uses 105, polling uses 100 and offset reads use 120. Disconnecting a
connection does not log out its logical session. Server-observed activity renews
the session lease, and expiry or explicit logout retires it before capacity is
reused.

External group offsets belong to groups managed outside Iggy, such as a Kafka
gateway. They require no Iggy group membership and can exceed the partition's
message-offset range.

The bind proof is an independent session credential. Password changes and PAT
revocation or expiry do not end an established session. Binding checks that the
owner still exists and is active; current permissions still govern each request.
Explicit logout, lease expiry and user deactivation end session access.

clients_table_max and dedup_clients_max nominate immutable limits when the
first operation commits in each metadata or partition group. Recovery and state
transfer preserve those committed limits. Configuration changes affect groups
that have not committed a limit yet; existing groups log a mismatch and retain
their committed capacity. At capacity, new sessions or writers are refused until
ordered retirement releases slots. Live retry protection is never evicted.

HTTP writes using one session and partition serialize through the previous
write's reply or bounded deadline. With ack=none, a request returns 202 after
its dispatch, while the next request to that partition waits for the previous
write to settle. Waiters acquire the in-flight permit after the partition gate,
so they do not consume the session's budget for other partitions.

Durability defaults remain Replicated, and ordinary SDK sends and explicit
offset writes support the configured policy. Crash-safe send retries require
Persisted; crash-safe explicit offset retries require Persisted and
Quorum. Weaker policies can lose data and receipts on a crash. A retained
receipt still replays its original result, but a lost receipt cannot prevent
another execution. NoAck and internal auto-commit polls retain their weaker
completion contracts.

Auto-commit polls are at-most-once: the owner can advance an offset before the
gateway's final authorization check. If the session ends or permissions are
revoked during that return hop, the response is denied and the batch can be lost.

Topics have an immutable partition_resize_policy creation option. The default,
mutable, permits partition addition and deletion without a bounded cutover or
per-key ordering across partition layouts. fixed rejects both operations with
PartitionResizeDisabled (2023). Existing messages stay in their original
partitions. Neither policy prevents retention or deletion of the topic.
Set the option through TopicCreateOptions::partition_resize_policy, HTTP's
options map, or the CLI's --set partition_resize_policy=fixed.

Upgrade recovery

This release changes protocol and storage formats. Servers and compatible
clients deploy together; mixed versions and rolling upgrades are unsupported.
Peers verify protocol, release and storage-format identity before admission.
Executable packaging does not affect that identity, so stripping or rebuilding
the same compatible release does not by itself prevent a replica from joining.

This checkout uses development protocol 0.11.1. Finalize its version and minimum
before the next stable release. Released server-0.9.0 uses protocol 0.11.0 and
accepts every 0.11.x patch. An incompatible release therefore needs a new minor
protocol version, such as 0.12.0, for that released server to reject new clients
before decoding their login.

The protocol number is independent of server and SDK package versions. Its
current and minimum values are maintained manually for stable releases. The
client login gate checks the full inclusive range, including patch versions.
Both bounds stay equal unless compatibility with older released protocols is
explicitly supported. Edge builds require matching clients and servers, even
when their protocol numbers match.

An unsupported data directory is refused before WAL scanning or file changes.
Replacing the binary does not migrate old data. Restore or migrate retained data
only through a separately verified procedure; copying a format marker is not
a migration.

Partition recovery refuses a superblock whose nonzero log_view
is below the partition's committed created_view. It also refuses a WAL
certificate with a nonzero log_view below that floor. The log names the
superblock directory or the prepares-{revision} WAL directory and the
recorded views. The affected partition is tombstoned locally and is not served;
its durable files are preserved.

For a superblock with log_view zero, recovery raises view to at least
created_view without changing log_view. The raised view must be persisted
before any view-scoped send, just as when no superblock record exists.

Such state can have been written by older servers, including server-0.9.0,
after restarting before the partition's first superblock write. The creation
view was not restored on that path. Raising a nonzero log_view during an
upgrade would certify history that the replica may not hold, so the server
does not repair those records automatically.

Partition directories hold a created.revision file with the revision that
created the partition. Recovery deletes a directory whose file names an older
revision. The partition then starts empty, as if it had no directory, and in a
cluster the replica copies its data from the other replicas.

A directory that an older server made has no such file. A delete that did not
finish before a restart leaves such a directory on disk, and a later create
with the same stream, topic and partition ids finds it. Recovery then loads the
leftover directory as the new partition. Only a leftover superblock with a
nonzero log_view below created_view trips the superblock refusal. The WAL
refusal does not apply, because each partition has its own prepares-{revision}
WAL directory. Recovery logs adopting a nonempty partition directory without created.revision before adopting such a directory, since it cannot distinguish
a live legacy incarnation from a deleted one.

If recovery logs falls below committed creation view or the adoption warning
above, look at the history of the affected topic or partition first. If it was
deleted and created again with the same ids, delete it again, and then create
it again. The delete removes the leftover directory on every replica. It also
removes every message in that topic or partition, so do this only if you can
lose them.

In every other case:

  1. Pause writes to the affected partition and stop the affected cluster before
    changing any recovery files. Keep the refusal logs.
  2. Preserve a consistent copy of every replica's metadata, partition
    superblocks, WAL directories and segment files together with its configuration.
  3. Restore only from a verified consistent backup, or use a separately validated
    recovery procedure based on retained replica history. Confirm that it preserves
    acknowledged writes and agrees with the committed partition creation metadata.

There is no automatic in-place migration for below-floor log certificates. Do not edit
view numbers or delete superblocks or WAL directories to bypass the refusal:
an empty history could then replace committed data during a view change.

Before a partition can serve, initialization publishes its incarnation in
partition-initialization/<namespace>/created.revision under the system data
directory. This atomic record remains outside the partition directory, including
after that directory is lost. Preserve it with metadata and partition backups.
Storage format IGGY-BOUND-GRANTS-4 requires this initialization contract;
older data directories are refused before mutation.

Retirement also writes retirement.fence in that external namespace directory
before counting a permanently failed partition as retired. The fence names the
failed incarnation and keeps it offline on subsequent boots, even if its
partition directory is replaced. Healthy partitions and new logins can continue.
If the fence cannot be persisted, the session remains retained and retirement
retries. Temporary teardown tombstones and state transfer do not qualify as
permanent failures.

Partition retirement barriers batch up to 128 ended sessions per commit. Each
session still requires retirement reports covering every allocated partition
from a common replica quorum before metadata releases its registry slot. Segment
deletion does not invalidate those reports; changes to partition incarnations
do. Metadata snapshot format 10 persists that separate revision.

A committed partition that has never initialized on this replica can finish
initialization after a crash, including when all replicas stopped before their
first WAL frontiers were published. An initialized partition with no durable
prepare-WAL frontier remains fenced as missing history. A singleton refuses to
serve it; a replicated partition requires history from a healthy peer. If every
replica lost initialized history, restarting does not clear the fence.

Preserve the files and restore a verified consistent backup or use a validated
recovery procedure. If the partition is independently known to be empty, or its
data may be discarded, delete and recreate it through the metadata API. This
creates a new partition incarnation and removes the old data. Missing files
alone are not evidence that discarding that data is safe. Do not create a WAL
frontier or remove initialization records or recovery fences manually.

Systemd integration

Build with the systemd feature to enable readiness and watchdog notifications:

sh
cargo build --bin iggy-server --release --features systemd

The server sends READY=1 only after every enabled transport is bound and accepting, so a unit ordered after it can dial as soon as it is notified. When the unit sets WatchdogSec=, the server pings WATCHDOG=1 at half that interval. On shutdown it sends STOPPING=1, which stops a long drain from counting against the watchdog.


Overview (`core/simulator/README.md`)

Apache Iggy Simulator

A deterministic harness that runs an Iggy cluster inside a single thread. The replicas are the real shard::IggyShard, running the real message pump, VSR consensus, metadata state machine and partitions. Only the environment under them is swapped: clock, journal storage, superblock, bus and network are in-memory doubles, and every task runs on a cooperative executor whose poll order is drawn from the run's seed.

A run is a pure function of that seed. Failures print it, and --seed <value> replays the same task interleaving, the same dropped packets, the same crashes, down to the prepare timestamps recorded in the log. Three production consensus bugs have been found this way, each of them reproducible from one seed and a handful of flags.

The crate is publish = false: a tool to run against the workspace, not a dependency.

Quick start

sh
# 10k ticks of partition-plane traffic, 3 replicas, perfect network
cargo run -p simulator --bin workload-fuzz

# metadata plane against a hostile network with crashes and restarts
cargo run -p simulator --bin workload-fuzz -- \
  --seed 48 --ticks 4000 --plane metadata --faults heavy \
  --crash-prob 0.02 --restart-prob 0.08 --crash-primary

# the properties below, pinned as regression tests
cargo test -p simulator

# scripted demo: send, create and delete a stream, crash a follower, poll
cargo run -p simulator --bin simulator-ui

Nothing here needs Docker, a server binary, or a network stack. core/integration is where real processes are spawned.

Real code, simulated environment

Layer What runs
Shard runtime, router, dispatch shard::IggyShard, one run_message_pump task per shard
Consensus consensus::VsrConsensus, one group for the metadata plane and one per partition namespace
Metadata, partitions the metadata STM, single writer on shard 0 with reader mirrors on the peers; partitions on the hash-owning shard
Client requests real wire messages built by SimClient; with --shell, the server's on_client_request path including login and session binding
Clock SimClock over executor virtual time, epoch pinned at 2026-01-01
Journal, storage SimJournal<MemStorage>, held by the harness so a WAL outlives the replica that wrote it
Superblock SimSuperblock; RAM has no torn writes, so it carries none of PingPongSuperblock's framing or checksums
Bus and network SimOutbox per replica, drained into PacketSimulator
Scheduling DetExecutor, uniform seeded pick among ready tasks
Snapshots the real SnapshotCoordinator writing real files, only under Simulator::with_checkpoints

The simulator feature on shard, metadata, partitions and server_common exposes four seams that only this crate uses: init_partition (bypasses the reconciler), seed_single_partition (bypasses metadata consensus), hold_borrow_across_await (feeds the borrow detector a known-bad case), and a fixed-salt password hash so replicated user metadata replays. None has a production caller, and a -p iggy-server build excludes all four.

Determinism

SimSeeds::derive(seed) splits the run's seed into one PRNG stream per consumer:

Stream Drives Why it is separate
network delays, drops, replays, partitions, clogs a workload change must not move the packet trace
workload which action, which entity, which argument it shared a stream with network once, which is why children are drawn from a parent rather than salted off the seed
executor task-poll and timer-fire order shaking out order dependence must not perturb traffic
entry_shard which shard receives each inbound packet one draw per delivered packet, the highest-rate stream
faults crash and restart scheduling both probabilities at zero draws nothing, so a fault-free run replays bit-identically
swarm --faults swarm's parameter draw correlating the loss probability with the loss events would collapse the explored space to a diagonal

Children are drawn from a parent PRNG in field order, so fields are appended to SimSeeds, never inserted: an insertion moves every child after it and re-locks every seeded baseline.

Time never flows on its own. DetExecutor::advance_time is the only thing that moves it, sleeps anchor their deadline at creation (matching compio::time::sleep, which the pump's re-arm relies on), and timers fire in (deadline, seq) order so equal deadlines resolve FIFO. run_pumps gives run_until_stalled a 100k poll budget; exhausting it means a task is spin-waking, and the panic names the seed and the schedule hash.

One tick

Simulator::step() returns the client replies delivered during it, in four phases:

  1. Fire the consensus tick timer (CONSENSUS_TICK_INTERVAL: view change, retransmits) and run every pump to quiescence.
  2. Deliver ready packets into the shard routers. The receiving shard is a seeded draw and so usually not the owner, which makes the frame take the real router hop, as production does when the coordinator homes a peer connection on an arbitrary shard. Under --shell a client packet enters through deliver_client_request instead of raw dispatch.
  3. Run the pumps again over the delivered frames and their loopback follow-ups, then drain each replica's outbox into the network.
  4. Advance network time.

Everything a step produces is on the wire before network time moves, so a reply cannot chain into another delivery inside the same tick.

The workload driver

workload::run_with_faults is the loop the fuzzer and most tests share. Per tick it advances the workload clock, steps the fault injector, then resends timed-out requests and handshakes before sampling anything new, since a timed-out request still holds its client's slot. It then samples at most one request per idle client, steps the simulator, classifies the replies, recovers evicted clients, and asserts the per-tick invariants.

Each workload action has a module under workload/ops/, 23 of them, one per Action variant, exposing the same seven items: Input, Outcome, OUTCOMES, sample, build_message, classify_reply, predicted_effect. Dispatch runs through the op_dispatch! macro, so an action without a module is a compile error rather than a silently unsampled op. Variant order in Action is part of the determinism contract: append, never insert or reorder.

A resend reuses the original encoded message verbatim. Rebuilding it would draw a fresh request id, and the metadata client table dedups on that id: a renumbered retry commits a second time instead of returning the cached reply. Resends move to the next replica, so a client whose primary died finds the new one. Each client holds one request in flight (CLIENT_REQUEST_QUEUE_MAX).

What gets checked

Check When Catches
workload::invariants every tick commit_offset or consensus view regressing on a live (replica, namespace), in-flight requests over the per-client bound
workload::state_checker every tick two replicas holding different history at an op they both committed, both directions of (commit_a == commit_b) == (checksum_a == checksum_b), and a break in the parent == checksum hash link
Simulator::assert_inboxes_drained every executor quiescence a frame sitting in an inbox nobody was woken for, which the next advance_time would otherwise drain silently
workload::oracle after the drain a live replica ahead of the leader, disagreement on any committed op two replicas share, a namespace in committed metadata with no host or more than one primary, and on a serial run a predicted Shadow that differs from the metadata committed on the leader

Agreement is asserted over the committed prefix, not over equal heads. A replica that missed the last commit broadcast, or rejoined a moment ago, is allowed to trail; what it may not do is hold different history at an op it did commit.

The oracle reports what it actually compared (ops_compared, replicas_compared, namespaces_checked) because a check that ran against an empty chain passes silently.

Using it from a test

rust
let seed = 0xC0FF_EE00;
let client_id: u128 = 1;
let network = PacketSimulatorOptions {
    node_count: 3,
    client_count: 1,
    seed,
    ..PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(3, std::iter::once(client_id), network);
let client = SimClient::new(client_id);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);

let mut options = WorkloadOptions::new(seed, 3, vec![ns]);
options.weights = ActionWeights::new(&[
    (Action::CreateStream, 50),
    (Action::DeleteStream, 25),
    (Action::SendMessages, 25),
]);
let mut wl = Workload::new(options);

let replies = workload::run(&mut sim, &mut wl, &[client], 2_000, u64::MAX);
assert!(replies > 0);
assert!(oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000));
oracle::assert_converged(&sim, &mut wl);

MemoryPool::init_pool has to run first with pooling disabled, since PooledBuffer::from panics on an uninitialised pool.
For the dispatch path use Simulator::with_shards_shell, where shell_login and seed_stream_topic_partition replace register_client_with_primary. To assert what was injected rather than trusting the probabilities to have fired, pass a caller-owned FaultInjector to run_with_faults; replica_crash and replica_restart drive faults directly.

workload-fuzz

text
workload-fuzz: seed=1 ticks=500 clients=1 replicas=3 plane=Partition faults=None shell=false crash_prob=0 quiesce=true
network: loss=0 replay=0 delay=1..3 partition=None/Symmetric p_partition=0 p_unpartition=0 stability=0/0 clog=0 clog_ticks=0 link_capacity=64
ran 500 ticks; 50 replies; crashes=0 restarts=0 still down: 0
coverage: replies_seen=50 replies_unknown=0 committed_rejections=0 samples_none=0 resends=0 denials=2 transients=0 evictions=0
  SendMessages: 28 commits, 0 denied (last status 0), 0 transient (last code 0)
  StoreConsumerOffset: 16 commits, 0 denied (last status 0), 0 transient (last code 0)
  DeleteConsumerOffset: 4 commits, 2 denied (last status 3021), 0 transient (last code 0)
quiesced and converged (leader-relative; entity oracle: held; evictions=0; ops_compared=1 replicas_compared=2 namespaces_checked=1)
coverage: replies_seen=51 replies_unknown=0 committed_rejections=0 samples_none=0 resends=0 denials=2 transients=0 evictions=0
commands delivered: Request=52 Prepare=80 PrepareOk=78 Reply=52 Commit=56 RequestPrepares=3 RepairPrepare=4 RepairDone=3
commands never delivered: Ping Pong PingClient PongClient StartViewChange DoViewChange StartView Eviction ...
workload-fuzz: OK (seed=1)

The two banner lines are the run's identity: every network parameter is printed, not the interesting subset, because under --faults swarm those values are the run. Coverage prints twice, before the quiesce assert and after the drain, so a failed drain still reports what the run managed to do. The command coverage lines answer "did this path get exercised?" by counting at delivery, so a command listed as delivered was seen on the wire rather than inferred from the source.

An invalid network configuration exits 2 before the simulator starts. Every other failure is a panic, and the hook prints reproduce with --seed N ahead of it.

Flags

Group Flags
Shape --replicas (3), --clients (1), --ticks (10000), --plane, --ack-quorum-ratio (0.5), --shell
Faults --faults, --crash-prob, --restart-prob, --crash-primary, --min-survivors (a commit quorum)
Network overrides --packet-loss-prob, --replay-prob, --one-way-delay-min, --one-way-delay-mean, --link-capacity, --partition-mode, --partition-symmetry, --partition-prob, --unpartition-prob, --partition-stability, --unpartition-stability, --clog-prob, --clog-duration-mean
Checkpointing --journal-slots, --data-dir, --reuse-data-dir
Quiesce --no-quiesce, --heal-before-quiesce
Gates --min-commits (1), --min-ops-compared (1), --require-entity-oracle, --require-faults

--plane picks the op mix: partition (writes and consumer offsets), metadata (replicated metadata mutations), mixed (stream creates over a write-heavy base), uniform (every action equally likely, the widest per-tick coverage).

--faults picks a whole network profile and the individual network flags override single fields of it.
Severity costs progress, every lost frame paying a resend timeout: on one namespace with one client, a 3-replica cluster drains roughly 440 replies in 5000 ticks on a perfect network, 240 under light, 40 under heavy.
Budget ticks accordingly instead of reading a low reply count as a stall.
--faults swarm derives every network parameter from the seed, which is what a campaign wants: none, light and heavy are three fixed points, so a thousand seeds against heavy is the same network a thousand times.

The gates exist because a run that commits nothing compares empty against empty and reports success. --min-commits and --min-ops-compared default to 1 for that reason; --require-entity-oracle additionally fails a run whose entity oracle was disarmed by an eviction and never re-armed.

--heal-before-quiesce restarts every crashed replica and stops the network drawing faults before the drain. It is off by default: a drain that fails with a replica still down is how a wedge shows up, and healing first resolves the wedge instead of reporting it.

Checkpoints

A checkpoint is forced by the metadata WAL running low on slots, so --journal-slots N is what makes one happen. Without it the journal is unbounded and WAL drain, snapshot_op movement, RangeEvicted and metadata state transfer are all unreachable however hard the cluster is driven. N must exceed SnapshotCoordinator::CHECKPOINT_MARGIN, or every commit checkpoints and the run measures that rather than the workload.

Snapshots are written to a real directory, per process, whose path is printed. It refuses an existing --data-dir unless --reuse-data-dir says otherwise, because a restart now recovers from snapshot.bin and the directory would be input to the new run.

Reproducing and diagnosing

sh
RUST_BACKTRACE=1 RUST_LOG=shard=debug,consensus=debug \
  cargo run -p simulator --bin workload-fuzz -- --seed 13 --plane metadata \
  --crash-prob 0.01 --restart-prob 0.05 --ticks 30000

Omitting --seed draws one and prints it on the banner, so an exploratory run stays replayable. The panic hook replaces the default one, so RUST_BACKTRACE is honoured explicitly and only when set; a campaign would otherwise be buried in backtraces. RUST_LOG selects the server's own tracing, which is the only record of a request dropped after logging, the shape that wedges a client's in-flight slot.

A failed drain prints every outstanding request with its action, target and attempt count, then one line per replica carrying its commit range, repair barrier, transfer state, primary claim and armed repair session, which usually names the failure without a debugger:

text
replica 2: live | metadata ... commit=1407..1414 barrier=1359 transferring=false primary=false repair=..1407@0

Tests

The suite lives at the end of src/lib.rs, with unit tests next to the modules they cover. Names follow given_X_when_Y_should_Z where there is a meaningful given; older behavioural names remain where there is not. Every test that touches replies initialises MemoryPool first, as above.

What the suite pins, by example: workload_replay_is_deterministic and multi_shard_replay_is_deterministic (the seed contract), committed_metadata_agrees_across_replicas, given_advanced_view_when_metadata_replica_restarts_should_recover_view_from_superblock, given_superblock_write_fails_when_primary_crashes_should_withhold_votes_and_not_elect, checkpointing_cluster_serves_a_chunked_state_transfer, shell_detects_partition_borrow_held_across_await.

Constructors: Simulator::new (one shard per replica), with_shards (metadata on shard 0, partitions hash-assigned across all shards), with_shards_shell (the same plus the real dispatch handlers), with_checkpoints. Multi-shard and shell-on-multi-shard are library-only; workload-fuzz always builds one shard per replica.

Not modelled

  • Client heartbeat verification and periodic consumer-group session expiry. Disconnect detaches a local binding and leaves the durable session live. The consumer-group tests invoke ordered cleanup explicitly; they do not cover the production timer or automatic liveness reporting.
  • Storage faults, beyond two knobs on the superblock. SimSuperblock::set_fail_writes and set_yield_writes inject a persistent write fault and an fsync-wide suspension point; MemStorage under the journal never fails and never tears a write. Partition superblocks are storeless, which leaves partition view recovery untested.
  • Segment files. Partition messages live in memory; they survive a restart only because the harness carries RetainedPartitionState across the rebuild.
  • Physical partition durability. The consensus model uses persisted topic policies and retains the partition journal, committed frontier and retry receipts together across restarts. Storage is ideal: it cannot lose a write or reorder persistence barriers. WAL/checkpoint fault tests and real-server crash tests must prove those physical properties separately.
  • Packet corruption. Packets are delayed, dropped, duplicated, partitioned and clogged, never mangled.
  • Multi-shard metadata. Shard 0 owns the only metadata consensus group.
  • I/O of any kind except the checkpoint files: no io_uring, no sockets, no wall clock.

Layout

text
src/
├── lib.rs              Simulator: cluster construction, step(), crash/restart, partition seeding, tests
├── executor/           DetExecutor (seeded cooperative scheduler) + virtual clock
├── packet.rs           PacketSimulator: delay, loss, replay, partitions, clogs, link capacity
├── network.rs          Network: passthrough over PacketSimulator, submit/step/heal
├── bus.rs              SimOutbox: per-replica staging that consensus sends into
├── replica.rs          Replica type alias and new_shard() wiring, mirroring server bootstrap
├── deps.rs             SimClock, MemStorage, SimJournal, SimSuperblock
├── client.rs           SimClient: builds real wire requests
├── seeds.rs            SimSeeds: one PRNG stream per consumer
├── ready_queue.rs      min-heap with reservoir-sampled random ready removal
├── workload/           driver, op modules, shadow, auditor, invariants, state checker, oracle
└── bin/
    ├── workload-fuzz.rs    the fuzzer
    └── simulator-ui.rs     scripted demo run

CI

A change under core/simulator/** marks the rust-simulator component in .github/config/components.yml, which scopes the Rust test tasks to it. The component is split out of rust-cluster so simulator-only changes do not trigger the foreign SDK suites. No fuzzing campaign runs in CI today; workload-fuzz is driven by hand, and a seed that fails should arrive as a test or as a finding write-up.

Commits touching this crate take the simulator scope: fix(simulator): ....


Overview (`examples/csharp/README.md`)

Iggy Examples

This directory contains comprehensive sample applications that showcase various usage patterns of the Iggy client SDK. To learn more about building applications with Iggy, please refer to the getting started guide.

Running Examples

Run from the Iggy repository root with .NET 10, using the server and SDK from the same checkout.
Initialize a new server data directory with the credentials used by the examples:

bash
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-server

For an existing server, use its configured credentials. Run each producer before its consumer.

For server configuration options and help:

bash
cargo run --bin iggy-server -- --help

You can also customize the server using environment variables:

bash
# Enable HTTP transport and set its address
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy IGGY_HTTP_ENABLED=true IGGY_HTTP_ADDRESS=127.0.0.1:3000 cargo run --bin iggy-server

Basic Examples

Getting Started

Perfect introduction for newcomers to Iggy:

bash
dotnet run --project  examples/csharp/src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Producer
dotnet run --project  examples/csharp/src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Consumer

These examples use IIggyClient with TCP transport and demonstrate stream/topic creation with basic message handling.

Basic Usage

Core functionality with detailed configuration options:

bash
dotnet run --project  examples/csharp/src/Basic/Iggy_SDK.Examples.Basic.Producer
dotnet run --project  examples/csharp/src/Basic/Iggy_SDK.Examples.Basic.Consumer

Demonstrates fundamental client connection, authentication, batch message sending, and polling over TCP or HTTP.

Message Pattern Examples

Message Headers

Shows metadata management using custom headers:

bash
dotnet run --project  examples/csharp/src/MessageHeaders/Iggy_SDK.Examples.MessageHeaders.Producer
dotnet run --project  examples/csharp/src/MessageHeaders/Iggy_SDK.Examples.MessageHeaders.Consumer

Demonstrates using HeaderKey/HeaderValue for message metadata instead of payload-based typing, with header-based message type dispatch in the consumer.

Message Envelopes

JSON envelope pattern for polymorphic message handling:

bash
dotnet run --project  examples/csharp/src/MessageEnvelope/Iggy_SDK.Examples.MessageEnvelope.Producer
dotnet run --project  examples/csharp/src/MessageEnvelope/Iggy_SDK.Examples.MessageEnvelope.Consumer

Uses MessagesGenerator to create OrderCreated, OrderConfirmed, and OrderRejected messages wrapped in JSON envelopes for type identification.

Security Examples

TCP/TLS

Demonstrates secure TLS-encrypted TCP connections:

bash
dotnet run --project  examples/csharp/src/TcpTls/Iggy_SDK.Examples.TcpTls.Producer
dotnet run --project  examples/csharp/src/TcpTls/Iggy_SDK.Examples.TcpTls.Consumer

Uses IggyClientConfigurator with TlsSettings (Enabled, Hostname, CertificatePath) to establish TLS-encrypted TCP connections with CA certificate verification. Run from the repository root so the CA path resolves. Start the server with the example certificate:

bash
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
IGGY_TCP_TLS_ENABLED=true \
IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
cargo run --bin iggy-server

Example Structure

All examples can be executed directly from the repository. Follow these steps:

  1. Start the matching server with the credentials shown above
  2. Run the producer and consumer using the concrete commands above
  3. Check source code

These examples use IggyClient with TCP transport and demonstrate automatic stream/topic creation with basic message handling.

The examples are automatically tested via scripts/run-examples-from-readme.sh --language csharp to ensure they remain functional and up-to-date with the latest API changes.


Overview (`examples/go/README.md`)

Iggy Examples

This directory contains a getting-started producer and consumer using the Go SDK over TCP, with optional TLS. To learn more about building applications with Iggy, please refer to the getting started guide.

Running Examples

Use Go 1.25 or newer. The local go.mod replaces the SDK dependency with ../../foreign/go, so build the server from the same checkout. Start it from the repository root, then run the Go commands from examples/go.

For disposable development data and no root credential environment overrides:

bash
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

--fresh wipes this replica's local data directory. IGGY_ROOT_USERNAME and IGGY_ROOT_PASSWORD take precedence over the flag. Bootstrap settings do not replace recovered credentials, including credentials recovered from cluster peers. The examples use iggy/iggy and numeric stream/topic IDs 0; run the producer first, before creating any other streams or topics.

For server configuration options and help:

bash
cargo run --bin iggy-server -- --help

You can also customize the server using environment variables:

bash
## Example: Enable HTTP transport and set custom address
IGGY_HTTP_ENABLED=true IGGY_HTTP_ADDRESS=127.0.0.1:3000 cargo run --bin iggy-server -- --with-default-root-credentials

Each producer sends 5 batches of 10 messages to partition 0. Each consumer reads that partition independently and exits after 5 batches; multiple consumers read the same messages and do not join a consumer group.

Basic Examples

Getting Started

Perfect introduction for newcomers to Iggy:

bash
go run ./getting-started/producer/main.go
go run ./getting-started/consumer/main.go

TLS Examples

From the repository root, start a disposable TLS server with the development certificate pair and the same credential prerequisites:

bash
IGGY_TCP_TLS_ENABLED=true \
IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

These certificates are for development only. From examples/go, run:

bash
go run ./getting-started/producer/main.go --tcp-server-address localhost:8090 --tls --tls-ca-file ../../core/certs/iggy_ca_cert.pem
go run ./getting-started/consumer/main.go --tcp-server-address localhost:8090 --tls --tls-ca-file ../../core/certs/iggy_ca_cert.pem

Example Structure

All examples can be executed directly from the repository. Follow these steps:

  1. Start the Iggy server: the Go SDK speaks the VSR wire protocol, so the
    examples need a VSR server.
    Use the source-server command and prerequisites above.
  2. Run the producer, then the consumer: use the commands above from examples/go.
  3. Check source code: Examples include detailed comments explaining concepts and usage patterns

These examples use IggyClient with TCP transport and demonstrate automatic stream/topic creation with basic message handling.

The examples are automatically tested via scripts/run-examples-from-readme.sh --language go to ensure they remain functional and up-to-date with the latest API changes.


Overview (`examples/java/README.md`)

Iggy Java Examples

This directory contains comprehensive sample applications that showcase various usage patterns of the Iggy java client SDK, from basic operations to advanced multi-tenant scenarios.

Java 17 or newer is required. The included Gradle wrapper downloads the pinned Gradle version. The project uses includeBuild to compile the SDK from foreign/java in the same checkout.

Running Examples

These examples target server 0.9.0 and speak the VSR (Viewstamped Replication) wire protocol. Build the server and SDK from the same checkout for unreleased changes. Run the Gradle tasks below from examples/java, with each producer before its consumer.

Iggy requires valid credentials to authenticate client requests. The examples assume that the server is using the default root credentials, set through environment variables before starting the server:

Linux, from the repository root:

bash
export IGGY_ROOT_USERNAME=iggy
export IGGY_ROOT_PASSWORD=iggy
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

PowerShell environment variables:

powershell
$env:IGGY_ROOT_USERNAME = "iggy"
$env:IGGY_ROOT_PASSWORD = "iggy"

Note
This setup is intended only for development and testing, not production use.

By default, all server data is stored in the local_data directory (this can be changed via IGGY_PATH).

--fresh wipes this replica's local data directory. Environment credentials take precedence over --with-default-root-credentials. Bootstrap settings do not replace recovered credentials, and a fresh cluster replica can recover them from peers. On an existing server, use the credentials that were configured for it.

You can also customize the server using environment variables:

bash
## Example: set a custom TCP address
IGGY_TCP_ADDRESS=127.0.0.1:8090 cargo run --bin iggy-server -- --with-default-root-credentials

Basic Examples

Getting Started

A good introduction for newcomers to Iggy:

bash
./gradlew runGettingStartedProducer
./gradlew runGettingStartedConsumer

Message Headers

Shows metadata management using custom headers:

bash
./gradlew runMessageHeadersProducer
./gradlew runMessageHeadersConsumer

Uses a message_type header to choose the application handler for each order event.

Message Envelopes

JSON envelope pattern for polymorphic message handling:

bash
./gradlew runMessageEnvelopeProducer
./gradlew runMessageEnvelopeConsumer

Uses MessagesGenerator to create OrderCreated, OrderConfirmed, and OrderRejected messages wrapped in JSON envelopes for type identification.

Advanced Examples

Multi-Tenant Architecture

Complex example demonstrating enterprise-level isolation:

bash
./gradlew runMultiTenantProducer
./gradlew runMultiTenantConsumer

Features multiple tenant setup, user creation with stream-specific permissions, concurrent producers/consumers across tenants, and security isolation.

High-Volume Data Generation

Testing and benchmarking support:

bash
./gradlew runSinkDataProducer

Produces 100 batches of 1000 to 1099 messages with generated user records.

Stream Builder Examples

Stream Builder

Producing and consuming messages in one class:

bash
./gradlew runStreamBasic

Uses the blocking client to create a stream and topic, send three messages, and poll them. It deletes its test_stream stream after the run.

Async Client Examples

Async Producer

Non-blocking batch production with concurrent request submission:

bash
./gradlew runAsyncProducer

Shows:

  • CompletableFuture chaining patterns
  • Submitting multiple sends without blocking

Async Consumer

Non-blocking async consumption with advanced patterns:

bash
./gradlew runAsyncConsumer

Shows:

  • Backpressure management (don't poll faster than you can process)
  • Error recovery with exponential backoff
  • Thread pool separation (Netty I/O threads vs. processing threads)
  • Offset-based polling with CompletableFuture

CRITICAL ASYNC PATTERN - Thread Pool Management:

The async client uses Netty's event loop threads for I/O operations. NEVER block these threads with:

  • .join() or .get() inside thenApply/thenAccept
  • Thread.sleep()
  • Blocking database calls
  • Long-running computations

If your message processing involves blocking operations, offload to a separate thread pool using thenApplyAsync(fn, executor).

Security Examples

TCP/TLS

Demonstrates secure TLS-encrypted TCP connections:

bash
./gradlew runTcpTlsProducer
./gradlew runTcpTlsConsumer

These examples require a TLS-enabled Iggy server. From the repository root, start a disposable server with the development certificates:

bash
IGGY_TCP_TLS_ENABLED=true \
IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

Uses IggyTcpClientBuilder.enableTls() and tlsCertificate("../../core/certs/iggy_ca_cert.pem") with CA verification. Run the clients from examples/java so that path resolves. The same data and credential prerequisites apply; these certificates are for development only.

Blocking vs. Async - When to Use Each

The Iggy Java SDK provides two client types: blocking (synchronous) and async (non-blocking). Choose based on your use case:

Use Blocking Client When

  • Writing scripts, CLI tools, or simple applications
  • Sequential code is easier to reason about
  • Integration tests

Use Async Client When

  • Need high throughput
  • Application is already async/reactive (Spring WebFlux, Vert.x)
  • Want to compose non-blocking requests with CompletableFuture
  • Building services that handle many concurrent streams

Key Async Patterns

CompletableFuture Chaining

java
client.connect()
    .thenCompose(v -> client.login())
    .thenCompose(identity -> client.streams().createStream("my-stream"))
    .thenAccept(stream -> System.out.println("Created: " + stream.name()))
    .exceptionally(ex -> {
        System.err.println("Error: " + ex.getMessage());
        return null;
    });

Submitting Multiple Sends

java
List<CompletableFuture<SendMessagesResponse>> sends = new ArrayList<>();
for (int i = 0; i < 10; i++) {
    sends.add(client.messages().sendMessages(...));
}
CompletableFuture.allOf(sends.toArray(new CompletableFuture[0])).join();

The client accepts these calls without blocking, but its single VSR-pinned TCP
connection processes them in order. Batch more messages into each send to improve
throughput.

Thread Pool Offloading

java
// WRONG - blocks Netty event loop
client.messages().pollMessages(...)
    .thenAccept(polled -> {
        saveToDatabase(polled);  // blocking I/O!
    });

// CORRECT - offloads to processing pool
var processingPool = Executors.newFixedThreadPool(8);
client.messages().pollMessages(...)
    .thenAcceptAsync(polled -> {
        saveToDatabase(polled);  // runs on processingPool
    }, processingPool);

Overview (`examples/node/README.md`)

Iggy Examples

This directory contains comprehensive sample applications that showcase various usage patterns of the Iggy client SDK for Node.js, from basic operations to advanced scenarios. To learn more about building applications with Iggy, please refer to the getting started guide.

Running Examples

These examples target server 0.9.0 and use the SDK from the same checkout. Start the server from the repository root in a separate terminal:

bash
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

--fresh deletes existing local server data. Explicit credential environment variables override the default-root flag, and bootstrap settings do not replace recovered credentials. See the repository development prerequisites.

For server configuration options and help, from the repository root:

bash
cargo run --bin iggy-server -- --help

You can also customize the server using environment variables:

bash
# Enable HTTP transport and set the TCP address
IGGY_HTTP_ENABLED=true IGGY_TCP_ADDRESS=127.0.0.1:8090 \
  cargo run --bin iggy-server -- --fresh --with-default-root-credentials

From the repository root, build the local SDK before installing the examples:

bash
npm --prefix foreign/node ci
npm --prefix foreign/node run build
npm --prefix examples/node ci
cd examples/node

Run the following scripts from examples/node. Set DEBUG=iggy:examples* to see progress. The separate producer and consumer scripts do not consistently share resources: envelope/header consumers create empty topics, and the tenant producer removes its streams before exiting. Getting-started/basic producers spread batches over five partitions while their consumers poll partition 0. The stream-builder example below is a self-contained send-and-consume walkthrough.

Basic Examples

Getting Started

Producer and consumer scripts:

bash
npm run test:getting-started:producer
npm run test:getting-started:consumer

Basic Usage

Core functionality with detailed configuration options:

bash
npm run test:basic:producer
npm run test:basic:consumer

Demonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP transport.

Message Envelope

Working with message envelopes:

bash
npm run test:message-envelope:producer
npm run test:message-envelope:consumer

Demonstrates a JSON envelope containing message_type and a serialized payload.

Message Headers

Dispatching by a message type stored in the payload:

bash
npm run test:message-headers:producer
npm run test:message-headers:consumer

This example stores messageType and data in a JSON payload wrapper.

Multi-Tenant

Multi-tenant application patterns:

bash
npm run test:multi-tenant:producer
npm run test:multi-tenant:consumer

Each script uses one client with the supplied credentials to access separate tenant streams. Consumers poll with Consumer.Single.

Stream Builder

Creating a stream and topic, then sending and consuming messages:

bash
npm run test:stream-builder

Uses the standard client API to send and consume three messages, then deletes the resources it created.

Sink Data Producer

Generating messages for sink connectors:

bash
npm run test:sink-data-producer

Produces 100 batches of 100-499 JSON records. On completion it deletes the selected topic and stream, including resources that already existed.

TLS Examples

TCP/TLS

Producer and consumer examples using TLS-encrypted TCP connections with custom CA certificates:

bash
npm run test:tcp-tls:producer
npm run test:tcp-tls:consumer

These examples require a TLS-enabled Iggy server. From the repository root:

bash
IGGY_TCP_TLS_ENABLED=true \
IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

The clients use transport: 'TLS', connect to localhost, and load ../../core/certs/iggy_ca_cert.pem relative to examples/node. These are repository development certificates.

Local checks

bash
npm run test:unit
npm run lint

Overview (`examples/php/README.md`)

Iggy PHP Examples

This directory contains sample applications that show how to use the Apache
Iggy PHP SDK extension.

Running Examples

Start the server from the repository root:

bash
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

From examples/php, build the PHP extension and point PHP at it:

bash
(cd ../../foreign/php && cargo build)
export PHP_IGGY_EXTENSION="$(pwd)/../../foreign/php/target/debug/libiggy_php.so"

On macOS, use ../../foreign/php/target/debug/libiggy_php.dylib instead.

Getting Started

bash
php -d extension="${PHP_IGGY_EXTENSION:-../../foreign/php/target/debug/libiggy_php.so}" getting-started/producer.php
php -d extension="${PHP_IGGY_EXTENSION:-../../foreign/php/target/debug/libiggy_php.so}" getting-started/consumer.php

Basic Usage

bash
php -d extension="${PHP_IGGY_EXTENSION:-../../foreign/php/target/debug/libiggy_php.so}" basic/producer.php
php -d extension="${PHP_IGGY_EXTENSION:-../../foreign/php/target/debug/libiggy_php.so}" basic/consumer.php

The examples use IGGY_CONNECTION_STRING when it is set; use a binary transport
with credentials for automatic login. Otherwise they connect over TCP using
IGGY_HOST and IGGY_PORT, then log in with IGGY_USERNAME and IGGY_PASSWORD
without URL encoding. Defaults are 127.0.0.1:8090 and iggy/iggy.


Overview (`examples/python/README.md`)

Iggy Examples

This directory contains Python SDK examples for connection configuration, sending and polling messages, user headers, and TLS. To learn more about building applications with Iggy, please refer to the getting started guide.

Running Examples

These examples target server 0.9.0. For unreleased changes, build the SDK and
server from the same source checkout. Start the server in a separate terminal,
from the repository root:

bash
# Server 0.9.0
docker run --rm \
  --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \
  -p 8090:8090 \
  -e IGGY_TCP_ADDRESS=0.0.0.0:8090 \
  -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \
  -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
  apache/iggy:0.9.0

# Or build from source
cargo run --bin iggy-server -- --with-default-root-credentials --fresh

The container variables expose the TCP listener and bootstrap iggy/iggy for
new data. Stored credentials are not replaced, and environment credentials take
precedence over the source command's default-credentials flag. Use --fresh
only with disposable local replica data.

For server configuration options and help:

bash
cargo run --bin iggy-server -- --help

You can also customize the server using environment variables:

bash
# Enable HTTP transport and set its address
IGGY_HTTP_ENABLED=true IGGY_HTTP_ADDRESS=127.0.0.1:3000 cargo run --bin iggy-server -- --with-default-root-credentials

With Python 3.11 or newer and Rust/Cargo available, install dependencies from
examples/python. uv selects the local SDK path in pyproject.toml; pip needs
that path explicitly:

bash
# Using uv
uv sync

# Using pip with the dependencies declared in pyproject.toml
python -m venv .venv
source .venv/bin/activate
pip install ../../foreign/python .

Basic Examples

High-Level Producer and Consumer

The Python high-level producer API is a port of the Rust high-level producer
API. For detailed producer behavior and configuration, see the
Rust high-level SDK documentation.

The high-level producer binds the destination once, initializes missing
resources, applies producer-level batching and retry settings, and shuts down
deterministically through an async context manager. The high-level consumer
joins a consumer group, polls all assigned partitions, invokes an async handler,
and commits each message after it has been handled.

Run either producer first. Both create the stream and topic and publish 12
messages for the consumer, which exits after receiving all of them. producer.py
uses direct mode and waits for server confirmations; background_producer.py
uses bounded background workers and flushes them on context-manager exit:

bash
# Using uv
uv run high-level/producer.py
uv run high-level/consumer.py

# Or use the background producer before starting the same consumer
uv run high-level/background_producer.py
uv run high-level/consumer.py

# Without using uv
python high-level/producer.py
python high-level/consumer.py

# Or use the background producer before starting the same consumer
python high-level/background_producer.py
python high-level/consumer.py

The existing examples below use the low-level IggyClient.send_messages() API
and remain useful when each call needs to specify its own destination.

Getting Started

Perfect introduction for newcomers to Iggy:

bash
# Using uv
uv run getting-started/producer.py
uv run getting-started/consumer.py

# Without using uv
python getting-started/producer.py
python getting-started/consumer.py

Basic Usage

Core functionality with detailed configuration options:

bash
# Using uv
uv run basic/producer.py
uv run basic/consumer.py

# Without using uv
python basic/producer.py
python basic/consumer.py

Demonstrates client connection, authentication, batch message sending, and polling
over TCP, QUIC, or WebSocket. HTTP requires an explicit login call; its
connection-string credentials are not applied automatically.

Message Headers

Shows how to attach and read Python SDK user headers with str, bytes, bool, int, and float values. Two variants share their logic through message-headers/common.py:

  • plain-headers/ uses the convenient dict[str, str | bytes | bool | int | float] form; the SDK infers a wire type for each value.
  • typed-headers/ uses explicit HeaderKey/HeaderValue for full control over the wire type.

Both producers store typed headers on the wire. The plain consumer converts them to Python scalars, while the typed consumer preserves and inspects the explicit header kinds.

bash
# Using uv
uv run message-headers/plain-headers/producer.py
uv run message-headers/plain-headers/consumer.py
uv run message-headers/typed-headers/producer.py
uv run message-headers/typed-headers/consumer.py

# Without using uv
python message-headers/plain-headers/producer.py
python message-headers/plain-headers/consumer.py
python message-headers/typed-headers/producer.py
python message-headers/typed-headers/consumer.py

TLS Examples

To test with a TLS-enabled server, start the server with TLS configured (see main README), then run:

bash
uv run getting-started/producer.py --tcp-server-address localhost:8090 --tls --tls-ca-file ../../core/certs/iggy_ca_cert.pem
uv run getting-started/consumer.py --tcp-server-address localhost:8090 --tls --tls-ca-file ../../core/certs/iggy_ca_cert.pem

Overview (`examples/rust/README.md`)

Iggy Examples

This directory contains comprehensive sample applications that showcase various usage patterns of the Iggy client SDK, from basic operations to advanced multi-tenant scenarios. To learn more about building applications with Iggy, please refer to the getting started guide.

Running Examples

Run all commands from the repository root, with the server in a separate terminal.
For unreleased changes, use the SDK and server from the same checkout.

Iggy requires valid credentials to authenticate client requests. The examples assume that the server is using the default root credentials, which can be enabled in one of two ways:

  1. Start the server with default credentials:

    bash
    cargo run --bin iggy-server -- --with-default-root-credentials
  2. Set the appropriate environment variables before starting the server with cargo run --bin iggy-server:

    macOS/Linux:

    bash
    export IGGY_ROOT_USERNAME=iggy
    export IGGY_ROOT_PASSWORD=iggy

    Windows(Powershell):

    bash
    $env:IGGY_ROOT_USERNAME = "iggy"
    $env:IGGY_ROOT_PASSWORD = "iggy"

Note
This setup is intended only for development and testing, not production use.

By default, all server data is stored in the local_data directory (this can be changed via system.path in config.toml).

Root credentials bootstrap a new state. Environment credentials override the default-credential flag; bootstrap settings do not replace recovered credentials.
Once the server has created and populated the data directory, the existing stored credentials will always be used, and supplying the --with-default-root-credentials flag or setting the environment variables will no longer override them.

If the server has already been started once and your example returns Error: InvalidCredentials, then this means the stored credentials differ from the defaults.

You can reset the credentials in one of two ways:

  1. Delete the existing data directory, then start the server again with the default-credential flag or environment variables.

  2. Use the --fresh flag to force a reset:

    bash
    cargo run --bin iggy-server -- --with-default-root-credentials --fresh

    This deletes this replica's local data and re-initializes it. Use disposable development data. In a cluster, a fresh replica can recover credentials from peers; a new cluster requires explicit root credentials.

For server configuration options and help:

bash
cargo run --bin iggy-server -- --help

You can also customize the server using environment variables:

bash
## Example: Enable HTTP transport and set custom address
IGGY_HTTP_ENABLED=true IGGY_HTTP_ADDRESS=127.0.0.1:3000 cargo run --bin iggy-server -- --with-default-root-credentials

You can run multiple producers and consumers simultaneously to observe how messages are distributed across clients. Most examples support configurable options via the Args struct, including transport protocol, stream/topic/partition settings, consumer ID, message size, and more.

Basic Examples

Getting Started

Perfect introduction for newcomers to Iggy:

bash
cargo run --example getting-started-producer
cargo run --example getting-started-consumer

These examples use IggyClientBuilder with TCP transport and demonstrate stream/topic creation with basic message handling. Run the producer before the consumer on a fresh server: the consumer expects stream and topic IDs 0.

Basic Usage

Core functionality with detailed configuration options:

bash
cargo run --example basic-producer
cargo run --example basic-consumer

Demonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP/QUIC/HTTP/WebSocket protocols.

To run the pair over HTTP:

bash
cargo run --example basic-producer -- \
  --transport http
cargo run --example basic-consumer -- \
  --transport http

Message Pattern Examples

Message Headers

Shows metadata management using custom headers:

bash
cargo run --example message-headers-type-producer
cargo run --example message-headers-type-consumer

Demonstrates using HeaderKey/HeaderValue for message metadata instead of payload-based typing, with header-based message routing.

Shows manual message compression using headers. This is the current workaround while built-in compression is unsupported:

bash
cargo run --example message-headers-compression-producer
cargo run --example message-headers-compression-consumer

Demonstrates typed header keys and values with various data types (strings, integers, floats, booleans, raw bytes):

bash
cargo run --example typed-headers-producer
cargo run --example typed-headers-consumer

Message Envelopes

JSON envelope pattern for polymorphic message handling:

bash
cargo run --example message-envelope-producer
cargo run --example message-envelope-consumer

Uses MessagesGenerator to create OrderCreated, OrderConfirmed, and OrderRejected messages wrapped in JSON envelopes for type identification.

Advanced Examples

Multi-Tenant Architecture

Complex example demonstrating enterprise-level isolation:

bash
cargo run --example multi-tenant-producer
cargo run --example multi-tenant-consumer

Features multiple tenant setup, user creation with stream-specific permissions, concurrent producers/consumers across tenants, and security isolation. Configurable via environment variables (TENANTS_COUNT, PRODUCERS_COUNT, etc.).

New SDK API

Modern, ergonomic SDK usage patterns:

bash
cargo run --example new-sdk-producer
cargo run --example new-sdk-consumer

Showcases the newer SDK APIs with simplified setup, automatic topic creation, consumer groups, and AutoCommit configuration.

High-Volume Data Generation

Testing and benchmarking support:

bash
cargo run --example sink-data-producer

Produces 100 batches of 100 to 499 random user records, with a direct-send request limit of 1000 messages. Connection and stream settings are configurable via environment variables.

Stream Builder Examples

Basic Stream Management

IggyStream abstraction for producer/consumer pairs:

bash
cargo run --example stream-basic
cargo run --example stream-producer
cargo run --example stream-consumer

Advanced Configuration

Comprehensive configuration examples with detailed documentation:

bash
cargo run --example stream-producer-config
cargo run --example stream-consumer-config

These examples document all available configuration options including partitioning strategies, retry policies, batching, AutoCommit strategies, polling strategies, and retry mechanisms.

Security Examples

TCP/TLS

Demonstrates secure TLS-encrypted TCP connections using custom CA certificates:

bash
cargo run --example tcp-tls-producer
cargo run --example tcp-tls-consumer

These examples require a TLS-enabled Iggy server. Start the server with:

bash
IGGY_TCP_TLS_ENABLED=true \
IGGY_TCP_TLS_CERT_FILE=core/certs/iggy_cert.pem \
IGGY_TCP_TLS_KEY_FILE=core/certs/iggy_key.pem \
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

Use the test certificates only for local development. The --fresh data-reset and credential qualifications above apply.

Uses IggyClientBuilder with TLS options (with_tls_enabled, with_tls_domain, with_tls_ca_file) to establish TLS-encrypted TCP connections with CA certificate verification.

Example Structure

All examples can be executed directly from the repository. Follow these steps:

  1. Start the Iggy server using the credential setup above
  2. Run desired example: cargo run --example EXAMPLE_NAME
  3. Check source code: Examples include detailed comments explaining concepts and usage patterns

Most examples use shared utilities from examples/rust/src/shared/ including:

  • Message type definitions (orders, events)
  • Message generation utilities
  • Common argument parsing
  • Client setup helpers

The examples are automatically tested via scripts/run-examples-from-readme.sh --language rust to ensure they remain functional and up-to-date with the latest API changes.


Overview (`foreign/cpp/README.md`)

Iggy C++ Client

C++ client for Apache Iggy message streaming.

Bazel uses the Cargo and Rust toolchain provisioned by rules_rust. The Rust bridge builds locally into a separate target directory in the Bazel output tree. Use the Bazel version pinned in .bazelversion with a compatible Java runtime (Java 21 or newer for Bazel 9.2.0).

Build commands

bash
# Build library
bazel build //:iggy-cpp

# Unit tests
bazel test //:unit

# Low-level integration tests (require a running server)
bazel test //:e2e

Binary mutations retain an uncertain operation until it is resolved or abandoned.
IggyException::OperationId() carries an exact std::uint64_t for
operation_uncertain, lane_blocked, and operation_not_retained.
Keep using the same client to recover:

cpp
try {
    client.CreateStream("events");
} catch (const iggy::IggyException &error) {
    if (error.Kind() == "operation_uncertain" && error.OperationId()) {
        client.ResolveOperation(*error.OperationId(), std::chrono::seconds(30));
    } else {
        throw;
    }
}

ResolveOperation retries the retained identity and proves a successful commit
without returning the original response payload. A proved committed business
rejection throws its original error. Both outcomes release the retained operation
and its lane. An operation_uncertain result remains retained.
AbandonOperation releases local state without undoing or determining the server
result. operation_not_retained does not prove noncommit, so submitting the old
payload again can duplicate work. A lane_blocked ID belongs to an earlier call;
resolve it before submitting the new request again. Polls remain transient and
are never retained or replayed.

Disconnect() and session reset discard retained operations without settling
their outcomes. Reconnecting cannot recover those discarded operations.
The client keeps the proved outcomes of its last max_operations replays, also
across a session reset. A later ResolveOperation of a proved operation returns
the same outcome without a request.
TCP, QUIC, and WebSocket support ResolveOperation and AbandonOperation;
HTTP reports feature_unavailable for both.

The blocking wrapper populates IggyException::Code() and Kind() for Rust SDK
errors from these methods:

  • Login, Logout, ResolveOperation, and AbandonOperation.
  • CreateUser, UpdateUser, and DeleteUser.
  • CreateStream, UpdateStream, and DeleteStream.
  • CreateTopic, UpdateTopic, and DeleteTopic.
  • CreatePartitions, DeletePartitions, and DeleteSegments.
  • CreateConsumerGroup, DeleteConsumerGroup, JoinConsumerGroup, and LeaveConsumerGroup.
  • StoreConsumerOffset, StoreConsumerPosition, and DeleteConsumerOffset.

Other methods, including reads and Connect, Disconnect, and Shutdown, report
errors through the exception message only. C++ and bridge-local argument errors
also leave Code() and OperationId() empty and Kind() empty, including for the
methods listed above.

Direct generated-bridge callers pass a local iggy::ffi::ErrorInfo as the last
argument to login/logout, mutations, polls, raw commands, and recovery calls.
It is reset at entry and populated only when the Rust SDK returns an IggyError.
Bridge-local argument errors leave it empty. Inspect kind, code, and, when
has_operation_id is true, operation_id. Use separate values for concurrent
calls; error state is never stored globally or on the client.

Concurrent calls on one client are safe. Moving, assigning, or destroying the
client while any call is in progress is undefined behavior.

StoreConsumerOffset and DeleteConsumerOffset require an explicit partition on
TCP, QUIC, and WebSocket; an absent partition returns invalid_identifier.
An omitted partition, including the default on DeleteConsumerOffset, is for
HTTP only.

The binary session defaults to 256 active or retained mutations and 64 MiB of
frozen request bytes. Configure both limits with
Builder().WithPendingOperations(max_operations, max_bytes). Both must be positive.
FromConnectionString accepts the same limits as max_pending_operations and
max_pending_operation_bytes query parameters for TCP, QUIC, and WebSocket.
HTTP continues to use its configured transport behavior and has no retained
operation recovery.


Overview (`foreign/csharp/README.md`)

C# SDK for [Iggy](https://github.com/apache/iggy) [](https://www.nuget.org/packages/Apache.Iggy)

Overview

The Apache Iggy C# SDK provides a comprehensive client library for interacting with Iggy message streaming servers. It
offers a modern, async-first API with support for multiple transport protocols and comprehensive message streaming
capabilities.

Getting Started

Installation

Install the NuGet package:

bash
dotnet add package Apache.Iggy --version 0.9.0

The package installation and API examples target server/SDK 0.9.0. For source builds, use the server and SDK
from the same checkout. The SDK targets .NET 8 and .NET 10; repository examples require .NET 10.
0.9.0 includes the independent message and consumer-offset durability options.

This checkout uses binary protocol 0.11.1. Servers and applicable SDKs must deploy together; mixed versions and
rolling upgrades are unsupported. This checkout requires fresh storage and refuses older data directories
without modification. --fresh deletes existing data in the configured directory.
See the server recovery guide.
Register creates a shared logical session; other TCP connections authenticate with BindSession (15), using the
parent's identity and proof. A disconnect removes its binding without ending the session. Logout or committed expiry
ends it; a new session must not replay an unresolved mutation from the old one.
GetPartitionRoute (105) discovers partition primaries. Commands 14, 103, 104 and 123 are retired.
Ordinary SDK sends and explicit offset mutations support the configured durability, including the Replicated default.
Crash-safe send retries require Persisted message durability; crash-safe explicit offset retries require Persisted
offset durability and Quorum acknowledgement. Weaker policies can lose data and receipts on a crash. HTTP NoAck's
202 confirms dispatch, not partition admission or commit, and supplies no recoverable caller receipt.

Supported Protocols

The SDK supports two transport protocols:

  • TCP - Binary protocol for optimal performance and lower latency (recommended)
  • HTTP - RESTful JSON API for stateless operations

Over TCP the SDK speaks the VSR consensus framing, which is the only wire protocol the server accepts.

See Viewstamped Replication (VSR) for what that means for the client API.

Creating a Client

The SDK is built around the IIggyClient interface. The API fragments below reuse this client and
these imports. Create the named resources before read, update, delete, send, or poll operations:

c#
using System.Buffers;
using System.Text;
using System.Text.Json;
using Apache.Iggy;
using Apache.Iggy.Configuration;
using Apache.Iggy.Consumers;
using Apache.Iggy.Contracts;
using Apache.Iggy.Contracts.Auth;
using Apache.Iggy.Enums;
using Apache.Iggy.Factory;
using Apache.Iggy.Headers;
using Apache.Iggy.IggyClient;
using Apache.Iggy.Kinds;
using Apache.Iggy.Messages;
using Apache.Iggy.Publishers;
using Microsoft.Extensions.Logging;
using Partitioning = Apache.Iggy.Kinds.Partitioning;


using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
{
    BaseAddress = "127.0.0.1:8090",
    Protocol = Protocol.Tcp
});

await client.ConnectAsync();
await client.LoginUserAsync("iggy", "iggy");

Optionally, you can provide an ILoggerFactory for diagnostics and debugging (defaults to
NullLoggerFactory.Instance). Add Microsoft.Extensions.Logging.Console to use AddConsole:

c#
using var loggerFactory = LoggerFactory.Create(builder =>
{
    builder
        .AddFilter("Apache.Iggy", LogLevel.Information)
        .AddConsole();
});

using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
{
    BaseAddress = "127.0.0.1:8090",
    Protocol = Protocol.Tcp,
    LoggerFactory = loggerFactory
});

await client.ConnectAsync();

Configuration

The IggyClientConfigurator provides comprehensive configuration options:

c#
using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
{
    BaseAddress = "127.0.0.1:8090",
    Protocol = Protocol.Tcp,

    // Socket buffer sizes in bytes (optional, null = OS default)
    ReceiveBufferSize = null,
    SendBufferSize = null,

    // Idle ping keeping the session alive under server-side heartbeat verification (TCP only).
    // Default 5 seconds, must be between 1 millisecond and about 49 days. Init-only.
    HeartbeatInterval = TimeSpan.FromSeconds(5),

    // Automatic reconnection with exponential backoff (enabled by default, infinite retries)
    ReconnectionSettings = new ReconnectionSettings
    {
        Enabled = true,
        MaxRetries = 0,              // 0 = infinite retries
        InitialDelay = TimeSpan.FromSeconds(5),
        MaxDelay = TimeSpan.FromSeconds(30),
        WaitAfterReconnect = TimeSpan.FromSeconds(1),
        UseExponentialBackoff = true,
        BackoffMultiplier = 2.0
    },

    // Auto-login after connection. Reconnect first binds the retained session; configured or
    // remembered LoginUserAsync credentials allow a fresh login after that session ends
    AutoLoginSettings = AutoLoginSettings.For("iggy", "iggy"),
    // or AutoLoginSettings.ForPersonalAccessToken("your_token")

    // Optional: logging
    LoggerFactory = loggerFactory
});

await client.ConnectAsync();

TCP applies the connection, heartbeat, TLS, and auto-login settings. HTTP ConnectAsync does no
network work and requires explicit login. For TLS configuration, see the TcpTls example.

Connection String

A TCP client can also be created from the connection string format shared with the other SDKs. The
credentials become the auto-login settings, so the client signs in on ConnectAsync:

c#
using var client = IggyClientFactory.CreateClient("iggy://iggy:[email protected]:8090");
await client.ConnectAsync();

// A personal access token instead of a username and password, plus options
using var tokenClient = IggyClientFactory.CreateClient(
    "iggy+tcp://[email protected]:8090?heartbeat_interval=10s&reconnection_retries=5");

// Parse into a configurator to adjust the remaining settings
var config = IggyClientConfigurator.FromConnectionString("iggy://iggy:[email protected]:8090");
config.LoggerFactory = loggerFactory;
Option Default Description
tls false true or false
tls_domain server host Server name for the TLS handshake, used for every node the client dials
tls_ca_file empty CA certificate path, read when connecting, required with tls=true
reconnection_retries unlimited Count or unlimited. 0 turns reconnection off, also after a lost connection
reconnection_interval 1s Fixed delay between attempts and before the first redial, at least 1ms
heartbeat_interval 5s Between 1ms and about 49 days
reestablish_after - Validated, then ignored. reconnection_interval paces the first redial
nodelay - Validated, then ignored. The client always disables Nagle

reestablish_after and nodelay are accepted so that strings shared with other SDKs keep parsing, but
they change nothing. The client has no reestablish_after window after a lost connection, and it always opens
sockets with NoDelay, so nodelay=false does not turn Nagle back on even though the Rust and Node.js SDKs leave
Nagle on by default.

reconnection_retries=0 differs from the Rust SDK. After a lost connection, Rust still makes one reconnect
attempt, but this client fails the request at once. It also differs from ReconnectionSettings.MaxRetries,
where 0 means unlimited, so the parser maps reconnection_retries=0 to Enabled = false instead.

Reconnection uses no exponential backoff, and after each reconnect the client waits
ReconnectionSettings.WaitAfterReconnect (1 second by default). Credentials are taken literally: they are
not percent-decoded and must not contain @ or :. Bracketed IPv6 hosts such as
iggy://iggy:iggy@[::1]:8090 are accepted. Durations look like 500ms, 5s or 1m30s. Only iggy:// and
iggy+tcp:// are supported. A malformed string throws FormatException, whose message never
contains the connection string.

Viewstamped Replication (VSR)

Over TCP every request is wrapped in a 256-byte consensus header, the client registers a consensus session at
login, and replicated writes complete after the required quorum acknowledges them.
Durability.Persisted additionally waits for persistence on that quorum; explicit offset stores have
an independent ConsumerOffsetDurability policy. A NoAck success is a weak acknowledgement and does not prove commit.
Uncertain replicated operations can be resolved through the recovery methods below.

c#
using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
{
    BaseAddress = "127.0.0.1:8090",
    Protocol = Protocol.Tcp,

    // Upper bound on a reply frame the server announces, 64 MiB by default.
    MaxResponseFrameSize = 64 * 1024 * 1024,
    MaxPendingOperations = 256,
    MaxPendingOperationBytes = 64L * 1024 * 1024,

    AutoLoginSettings = AutoLoginSettings.For("iggy", "iggy")
});

await client.ConnectAsync();

What changes under VSR

  • Login registers a shared session in this checkout. LoginUserAsync / LoginWithPersonalAccessTokenAsync
    authenticate Register. Auxiliary and reconnected transports bind the retained identity and proof without another Register.
    Disconnecting removes only that binding; Logout, committed lease expiry or user deactivation ends the session.
    A StaleClient eviction also removes only the binding of that link. The reconnect binds the same session
    again, so its consumer-group memberships stay.
    A committed StaleClient result means that the server ended the session, for example because the user was
    deleted or deactivated. The client drops that session, and with reconnection enabled it signs in with configured
    or remembered credentials. A StaleClient refusal before commit means only that the server detached the link
    that carried it from the session. A live session also gets this refusal after a replica replaced its client
    table. The client closes that link and binds the session again on a new link. If the session ended, that bind
    fails with Unauthenticated and the client signs in again. Otherwise the session, its retained operations and
    its consumer-group memberships stay.
    Password changes and PAT changes or expiry do not end an established session.
    A terminal BindSession refusal (Unauthenticated or StaleClient) permits a fresh login. A transient refusal
    retains the identity while that replica's background reconnect retries the bind. An unresolved mutation
    cannot be replayed under a fresh session. The older 0.9.0 connection-lifetime rule does not apply to this checkout.
  • Replica links are independent. The client proactively opens one shared session link per roster replica.
    Calls use the advertised metadata or partition primary. Replies are matched by request id, so concurrent
    reads and different partition groups can progress independently; cancelling one call preserves other calls.
    With a one-node roster, the client uses BaseAddress instead of the address that the node advertises, so a
    port mapping or a port-forward works.
  • The client picks partitions. Balanced and message-key partitioning are resolved
    client-side (xxHash32 matches Rust for identical encoded key bytes and partition counts), and consumer-group polls round-robin
    over the partitions the coordinator assigned to this client.
  • Consumer groups are assignment-based. JoinConsumerGroupAsync makes this client a member; the assignment
    is synced on demand and refreshed on every PingAsync. Partition counts are cached for 30 seconds, so a topic
    another client widens is picked up without waiting for a ping.
  • Credentials are bounds-checked locally. A username outside 3-50 bytes, a password outside 3-100 bytes or a
    personal access token outside 1-255 bytes is rejected before the register body is framed.
  • PingAsync costs more than a ping. Besides the ping it re-syncs the assignment of every consumer group
    this client has joined, so it makes one extra round trip per joined group. The TCP client pings on its own
    every HeartbeatInterval (5 seconds by default) while connected, so an idle session survives the server's
    heartbeat verification and assignments stay fresh; a lost session is repaired by the regular reconnect and
    auto login.

Retries and failed requests

Within one call deadline, the SDK retries an unresolved replicated mutation using its original session,
request id, context, and frozen frame. Metadata mutations share one lane; partition mutations share a lane
by incarnation and partition id, including requests using different names for the same partition.

  • IggyInvalidStatusCodeException carries the server status. FromServer distinguishes a server verdict
    from a local error, and Committed identifies a proved committed result.
  • VsrRequestOutcomeUnknownException.OperationId identifies a retained replicated mutation after its call
    ends without proof. Its lane stays blocked. VsrLaneBlockedException.OperationId identifies the operation
    blocking a subsequent mutation in the same lane; independent groups and pure reads can continue.
  • ResolveOperationAsync(operationId, timeout, token) replays that frozen operation. A proved success or
    business rejection releases the lane; a rejection throws its original status. Further uncertainty retains it.
    The client keeps the proved outcomes of its last MaxPendingOperations replays, also across a session
    reset. A later resolve of the same operation returns the same outcome without a request.
  • AbandonOperationAsync(operationId) releases local ownership without determining the outcome. It does
    not undo a committed operation. The operation stops counting against MaxPendingOperations at once.
    A ResolveOperationAsync that is still running is not canceled and keeps the lane until it ends, so later
    mutations of the lane still wait behind its replay.
    VsrOperationNotRetainedException means that no replay proved the outcome
    before a session reset, such as logout or session replacement, or an abandon. A proved outcome that aged
    out also causes it. It never proves non-commit and never authorizes an automatic fresh resend.

Auto-commit and Next polls hold a transient partition lane but are not retained or replayed after uncertainty.
Their unknown-outcome error has no operation id. IggyConsumer reports it through its error event and polls again.
Under AutoCommitMode.Auto, the server can store the offset of a batch whose reply was lost. A Next poll then
starts after that batch, so the consumer skips it. Behind a retained mutation these polls throw
VsrLaneBlockedException. IggyConsumer propagates it without resolving the blocker and ends its polling loop.
The application decides how to resume. Background IggyPublisher sends report unknown outcomes through the
message-batch-failed event; direct sends throw them to the caller.

IggyConsumer offset stores and deletes resolve any blocking operation before their own request. A background
IggyPublisher send resolves only a blocking batch of its own. Both continue after a proved commit or committed
rejection. The client keeps that outcome for the caller that received the operation id, such as the receiver of
the message-batch-failed event. That caller reads it with ResolveOperationAsync.

MaxPendingOperations and MaxPendingOperationBytes bound active plus retained mutation frames across the
client, defaulting to 256 operations and 64 MiB. Admission waits within the call deadline before allocating an
id or frozen frame. A replay shares its reservation; outstanding writers keep bytes charged until released.
Both limits must be positive. The byte limit includes the 256-byte header and the mutation body.
Smaller positive limits are valid but reject mutations that cannot fit; reads and login remain available.
Reads and control requests have a separate per-link limit of 256 pending calls and a 32-frame write queue.
Their bodies, including unknown raw non-replicated commands, are outside the mutation byte budget.

Limitations

  • TCP uses VSR framing; HTTP uses the separate REST API. Join/leave, GetMeAsync, segment deletion,
    and raw binary requests are TCP-only and throw FeatureUnavailableException on HTTP.
  • StoreOffsetAsync / DeleteOffsetAsync need an explicit partition id under VSR: the broker does not
    resolve a null partition for a consumer-offset request, so passing one throws client-side.
  • Polling a missing topic throws IggyInvalidStatusCodeException. An existing topic with no available
    messages returns an empty poll.

Behaviour changes for existing clients

  • IIggyPublisher.FlushUnsavedBufferAsync was removed. Set TopicOptions.Durability to
    Durability.Persisted when creating a topic to wait for message persistence.
  • MaxResponseFrameSize bounds the reply frames the VSR reader accepts. A reply larger than the 64 MiB
    default is refused and the connection is dropped, so raise it if a single response legitimately exceeds that
    • a large GetSnapshotAsync is the usual case.
  • Clients built with IggyConsumerBuilder / IggyPublisherBuilder now auto-login with the credentials passed
    to WithConnection. Before, a builder-created client came back from a
    reconnect unauthenticated; now the credentials are held for the lifetime of the connection and replayed.
  • The TCP client pings each ready replica every HeartbeatInterval (5 seconds). Each replica reconnects
    independently, with at most four concurrent dials, a two-second dial and TLS deadline, and jittered
    backoff capped at ten seconds. MaxRetries bounds one burst of consecutive retries per replica, and zero
    allows unlimited retries. While the client is signed in, each heartbeat restarts a replica link that used
    up its burst, with a fresh budget, so the client recovers from a longer outage without an application call.
    Logout and Dispose stop the restarts. Invalid local CA configuration and rejected credentials surface as
    errors. A caller's cancellation stops that call while the link owner continues reconnecting. Request replay
    has its own deadline.
    Configured auto-login credentials take precedence over credentials remembered from successful
    username/password or PAT login. Without either, reconnecting cannot restore the authenticated session.
    A committed password change or rename of the signed-in user updates these credentials. This includes a
    change that ResolveOperationAsync proves. A committed rejection leaves them unchanged.
  • AutoLoginSettings properties are now init-only, as is IggyClientConfigurator.HeartbeatInterval. Build
    them with an object initializer or the AutoLoginSettings.For / AutoLoginSettings.ForPersonalAccessToken
    factories instead of assigning after construction.
  • IggyConsumerBuilder / IggyPublisherBuilder accept a personal access token through the
    WithConnection(protocol, address, personalAccessToken, ...) overload, as an alternative to a username and
    password.
  • The SDK now ships a dependency on System.IO.Hashing, used for the client-side message-key partitioner.
  • TCP sockets use NoDelay to avoid delaying trailing request segments behind acknowledgements.

Authentication

User Login

Use the credentials configured for the server. The setup below initializes a new server with iggy / iggy:

c#
var response = await client.LoginUserAsync("iggy", "iggy");

Creating Users

Create new users with customizable permissions:

c#
var permissions = new Permissions
{
    Global = new GlobalPermissions
    {
        ManageServers = true,
        ManageUsers = true,
        ManageStreams = true,
        ManageTopics = true,
        PollMessages = true,
        ReadServers = true,
        ReadStreams = true,
        ReadTopics = true,
        ReadUsers = true,
        SendMessages = true
    }
};

await client.CreateUserAsync("test_user", "secure_password", UserStatus.Active, permissions);

// Login with the new user
var loginResponse = await client.LoginUserAsync("test_user", "secure_password");

Personal Access Tokens

Create and use Personal Access Tokens (PAT) for programmatic access:

c#
// Create a PAT
var patResponse = await client.CreatePersonalAccessTokenAsync("api-token", TimeSpan.FromHours(1));

// Login with PAT
await client.LoginWithPersonalAccessTokenAsync(patResponse!.Token);

Streams and Topics

Creating Streams

c#
await client.CreateStreamAsync("my-stream");

You can reference streams by either numeric ID or name:

c#
var streamById = Identifier.Numeric(0);
var streamByName = Identifier.String("my-stream");

Creating Topics

Every stream contains topics for organizing messages:

c#
var streamId = Identifier.String("my-stream");

await client.CreateTopicAsync(
    streamId,
    name: "my-topic",
    partitionsCount: 3,
    compressionAlgorithm: CompressionAlgorithm.None,
    messageExpiry: TimeSpan.MaxValue,  // never expire
    maxTopicSize: 1024 * 1024 * 1024   // 1 GiB
);

Note: Stream and topic names use hyphens instead of spaces. Iggy automatically replaces spaces with hyphens.

Publishing Messages

Sending Messages

Send messages using the publisher interface:

c#
var streamId = Identifier.String("my-stream");
var topicId = Identifier.String("my-topic");

var messages = new List<Message>
{
    new(Guid.NewGuid(), "Hello, Iggy!"u8.ToArray()),
    new(1, "Another message"u8.ToArray())
};

await client.SendMessagesAsync(
    streamId,
    topicId,
    Partitioning.None(),  // balanced partitioning
    messages
);

Partitioning Strategies

Control which partition receives each message:

c#
// Balanced partitioning (default)
Partitioning.None();

// Send to specific partition
Partitioning.PartitionId(1);

// Key-based partitioning (string)
Partitioning.EntityIdString("user-123");

// Key-based partitioning (integer)
Partitioning.EntityIdInt(12345);

// Key-based partitioning (GUID)
Partitioning.EntityIdGuid(Guid.NewGuid());

User-Defined Headers

Add custom headers to messages with typed values:

c#
var headers = new Dictionary<HeaderKey, HeaderValue>
{
    { HeaderKey.FromString("correlation_id"), HeaderValue.FromString("req-123") },
    { HeaderKey.FromString("priority"), HeaderValue.FromInt32(1) },
    { HeaderKey.FromString("timeout"), HeaderValue.FromInt64(5000) },
    { HeaderKey.FromString("confidence"), HeaderValue.FromFloat(0.95f) },
    { HeaderKey.FromString("is_urgent"), HeaderValue.FromBool(true) },
    { HeaderKey.FromString("request_id"), HeaderValue.FromGuid(Guid.NewGuid()) }
};

var messages = new List<Message>
{
    new(Guid.NewGuid(), "Message with headers"u8.ToArray(), headers)
};

await client.SendMessagesAsync(
    streamId,
    topicId,
    Partitioning.PartitionId(1),
    messages
);

Message durability

There is no on-demand flush command. Set TopicOptions.Durability = Durability.Persisted when creating
a topic to wait for message persistence on the required quorum. ConsumerOffsetDurability controls
explicit offset-store completion independently. Flush thresholds alone do not provide that guarantee.

Consumer Groups

Creating Consumer Groups

Coordinate message consumption across multiple consumers:

c#
var groupResponse = await client.CreateConsumerGroupAsync(
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    "my-consumer-group"
);

Joining and Leaving Groups

Note: Join/Leave operations are only supported on TCP protocol and will throw FeatureUnavailableException on HTTP.

c#
// Join a consumer group
await client.JoinConsumerGroupAsync(
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    Identifier.String("my-consumer-group")
);

// Leave a consumer group
await client.LeaveConsumerGroupAsync(
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    Identifier.String("my-consumer-group")
);

Consuming Messages

Fetching Messages

Fetch a batch of messages:

c#
var polledMessages = await client.PollMessagesAsync(new MessageFetchRequest
{
    StreamId = streamId,
    TopicId = topicId,
    Consumer = Consumer.New(1), // or Consumer.Group("my-consumer-group") for consumer group
    Count = 10,
    PartitionId = 0, // optional, null for consumer group
    PollingStrategy = PollingStrategy.Next(),
    AutoCommit = true
});

foreach (var message in polledMessages.Messages)
{
    Console.WriteLine($"Message: {Encoding.UTF8.GetString(message.Payload)}");
}

With AutoCommit = true, the server advances/submits the offset before application processing.
The poll reply does not wait for durable completion of that store. Use explicit offset stores after
processing when that distinction matters.

Polling Strategies

Control where message consumption starts:

c#
// Start from a specific offset
PollingStrategy.Offset(1000);

// Start from a specific timestamp (microseconds since epoch)
PollingStrategy.Timestamp(1699564800000000);

// Start from the first message
PollingStrategy.First();

// Start from the last message
PollingStrategy.Last();

// Start from the next unread message
PollingStrategy.Next();

Offset Management

Storing Offsets

Store the current consumer position:

c#
await client.StoreOffsetAsync(
    Consumer.New(1),
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    offset: 42,
    partitionId: 0
);

Retrieving Offsets

Get the current stored offset:

c#
var offsetInfo = await client.GetOffsetAsync(
    Consumer.New(1),
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    partitionId: 0
);

Console.WriteLine($"Stored offset: {offsetInfo!.StoredOffset}");

Deleting Offsets

Clear stored offsets:

c#
await client.DeleteOffsetAsync(
    Consumer.New(1),
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    partitionId: 0
);

System Operations

Cluster Information

Get cluster metadata and node information:

c#
var metadata = await client.GetClusterMetadataAsync();

Server Statistics

Retrieve server performance metrics:

c#
var stats = await client.GetStatsAsync();

Health Checks

Verify server connectivity:

c#
await client.PingAsync();

Client Information

Get information about connected clients:

c#
var clients = await client.GetClientsAsync();
var currentClient = await client.GetMeAsync();

Snapshots

Capture a system snapshot as a compressed ZIP archive:

c#
var snapshotBytes = await client.GetSnapshotAsync(
    SnapshotCompression.Zstd,
    new List<SystemSnapshotType>
    {
        SystemSnapshotType.ServerLogs,
        SystemSnapshotType.ServerConfig,
        SystemSnapshotType.ResourceUsage
    }
);

// Or capture everything
var fullSnapshot = await client.GetSnapshotAsync(
    SnapshotCompression.Deflated,
    new List<SystemSnapshotType> { SystemSnapshotType.All }
);

Available compression methods: Stored, Deflated, Bzip2, Zstd, Lzma, Xz.

Available snapshot types: FilesystemOverview, ProcessList, ResourceUsage, Test, ServerLogs, ServerConfig,
All.

Segment Management

Delete up to N of the oldest sealed segments from a partition; the active segment is retained:

c#
await client.DeleteSegmentsAsync(
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    partitionId: 1,
    segmentsCount: 2
);

Event Subscription

Subscribe to connection events:

c#
Func<ConnectionStateChangedEventArgs, Task> handler = async args =>
{
    Console.WriteLine($"Current connection state: {args.CurrentState}");
    await Task.CompletedTask;
};

client.SubscribeConnectionEvents(handler);

// Later
client.UnsubscribeConnectionEvents(handler);

Advanced: IggyPublisher

High-level publisher with background sending and retries. Retry settings apply to background sends;
maxAttempts includes the first send. Await the queue drain before disposal:

c#
using System.Buffers;
using System.Text;
using System.Text.Json;
using Apache.Iggy;
using Apache.Iggy.Consumers;
using Apache.Iggy.Kinds;
using Apache.Iggy.Messages;
using Apache.Iggy.Publishers;

await using var publisher = IggyPublisherBuilder.Create(
    client,
    Identifier.String("my-stream"),
    Identifier.String("my-topic")
)
.WithPartitioning(Partitioning.None())
.WithBackgroundSending(enabled: true, batchSize: 100)
.WithRetry(maxAttempts: 3)
.Build();

await publisher.InitAsync();

var messages = new List<Message>
{
    new(Guid.NewGuid(), "Message 1"u8.ToArray()),
    new(0, "Message 2"u8.ToArray())
};

await publisher.SendMessagesAsync(messages);

// Drain the background queue, then dispose
await publisher.WaitUntilAllSendsAsync();

For automatic object serialization, use the typed variant:

c#
await using var publisher = IggyPublisherBuilder<Order>.Create(
    client,
    Identifier.String("orders-stream"),
    Identifier.String("orders-topic"),
    new OrderSerializer()
).Build();

await publisher.InitAsync();
await publisher.SendAsync(new List<Order> { new(Guid.NewGuid(), 99.90m) });

record Order(Guid OrderId, decimal Amount);

class OrderSerializer : ISerializer<Order>
{
    public void Serialize(Order data, IBufferWriter<byte> writer) =>
        writer.Write(JsonSerializer.SerializeToUtf8Bytes(data));
}

Advanced: IggyConsumer

High-level consumer with automatic offset management and consumer groups:

c#
using System.Text;
using Apache.Iggy;
using Apache.Iggy.Consumers;
using Apache.Iggy.Kinds;

await using var consumer = IggyConsumerBuilder.Create(
    client,
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    Consumer.New(1)
)
.WithPollingStrategy(PollingStrategy.Next())
.WithBatchSize(10)
.WithAutoCommitMode(AutoCommitMode.Auto)
.Build();

await consumer.InitAsync();

await foreach (var message in consumer.ReceiveAsync())
{
    var payload = Encoding.UTF8.GetString(message.Message.Payload);
    Console.WriteLine($"Offset {message.CurrentOffset}: {payload}");
}

For consumer groups (load-balanced across multiple consumers):

c#
await using var consumer = IggyConsumerBuilder.Create(
    client,
    Identifier.String("my-stream"),
    Identifier.String("my-topic"),
    Consumer.Group("my-group")
)
.WithConsumerGroup("my-group", createIfNotExists: true, joinGroup: true)
.WithPollingStrategy(PollingStrategy.Next())
.WithAutoCommitMode(AutoCommitMode.AfterReceive)
.Build();

await consumer.InitAsync();

await foreach (var message in consumer.ReceiveAsync())
{
    var payload = Encoding.UTF8.GetString(message.Message.Payload);
    Console.WriteLine($"Partition {message.PartitionId}: {payload}");
}

For automatic deserialization:

c#
var builder = IggyConsumerBuilder<OrderEvent>.Create(
    client,
    Identifier.String("orders-stream"),
    Identifier.String("orders-topic"),
    Consumer.Group("order-processors"),
    new OrderDeserializer()
);
builder.WithPollingStrategy(PollingStrategy.Next());
builder.WithAutoCommitMode(AutoCommitMode.AfterReceive);

await using var consumer = builder.Build();
await consumer.InitAsync();

await foreach (var message in consumer.ReceiveDeserializedAsync())
{
    if (message.Status == MessageStatus.Success)
    {
        Console.WriteLine($"Order: {message.Data?.OrderId}");
    }
}

record OrderEvent(Guid OrderId, decimal Amount);

class OrderDeserializer : IDeserializer<OrderEvent>
{
    public OrderEvent Deserialize(ReadOnlyMemory<byte> data) =>
        JsonSerializer.Deserialize<OrderEvent>(data.Span)!;
}

API Reference

The SDK provides the following main interfaces:

  • IIggyClient - Main client interface (aggregates all features)
  • IIggyPublisher - Per-call message publishing interface
  • IIggyConsumer - Per-call message consumption interface
  • IIggyStream - Stream management
  • IIggyTopic - Topic management
  • IIggyOffset - Offset management
  • IIggyConsumerGroup - Consumer group operations
  • IIggyPartition - Partition operations
  • IIggySegment - Segment management
  • IIggyUsers - User and authentication management
  • IIggySystem - System and cluster operations
  • IIggyPersonalAccessToken - Personal access token management

Additionally, builder-based APIs are available:

  • IggyPublisherBuilder / IggyPublisherBuilder - Fluent publisher configuration
  • IggyConsumerBuilder / IggyConsumerBuilder - Fluent consumer configuration

Running Examples

Examples are located in examples/csharp/src/ in root iggy directory. Available examples:

  • GettingStarted - Basic producer/consumer setup
  • Basic - Simple message publishing and consuming
  • MessageHeaders - Using custom message headers
  • MessageEnvelope - Envelope pattern for message serialization
  • NewSdk - High-level IggyPublisher/IggyConsumer API

Start the matching source server from the repository root (use configured credentials for an existing data directory):

bash
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-server

Run an example from the examples/csharp/ directory. Run TcpTls from the repository root so its
certificate paths resolve:

bash
dotnet run -c Release --project src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Producer
dotnet run -c Release --project src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Consumer

Integration Tests

Integration tests are located in Iggy_SDK.Tests.Integration/. Tests can run against:

  • A dockerized Iggy server with TestContainers
  • A local Iggy server (set IGGY_SERVER_HOST environment variable)

Requirements

  • .NET 10 SDK
  • Docker (for TestContainers tests)

Running Integration Tests Locally

1. Dockerization

The suite runs against iggy-server. TCP only: the SDK frames TCP with the
VSR wire protocol, the cluster serves reads from the primary, and the HTTP surface has no equivalent path to
route them through.

bash
cargo build --bin iggy-server --bin iggy

docker build --no-cache -f core/server/Dockerfile --platform linux/amd64 --target runtime-prebuilt --build-arg PREBUILT_IGGY_SERVER=target/debug/iggy-server --build-arg PREBUILT_IGGY_CLI=target/debug/iggy -t iggy-server:test .

2. Build the Test Project

bash
dotnet build foreign/csharp/Iggy_SDK.Tests.Integration

3. Run test

bash
cd foreign/csharp
export IGGY_SERVER_DOCKER_IMAGE=iggy-server:test
dotnet test -f net10.0 --project Iggy_SDK.Tests.Integration --no-build --verbosity diagnostic

IGGY_SERVER_DOCKER_IMAGE defaults to iggy-server:test, so the export above is only needed to point
at a different image. Rider and Visual Studio need nothing configured.

Useful Resources

ROADMAP - TODO

  • Error handling with status codes and descriptions
  • Add support for ASP.NET Core Dependency Injection

Overview (`foreign/go/README.md`)

Go SDK for Iggy

Official Go client SDK for Apache Iggy message streaming.

The client speaks the VSR wire protocol over TCP, with or without TLS.
Concurrent requests share one managed connection per replica and one logical
session. VSR is the only protocol it supports.

Installation

The current source requires Go 1.25 or newer. From your application module,
install a release compatible with your server:

bash
go get github.com/apache/iggy/foreign/go

Unversioned go get selects the latest stable release. VSR support starts
with v0.9.0. For unreleased changes,
build both SDK and server from the same checkout; examples/go/go.mod
replaces this module with the local SDK source.

Running a server

From the repository root, build and start a VSR server using a new,
disposable data directory. The root environment variables bootstrap a new
instance; they do not replace credentials recovered from disk or peers:

bash
cargo build --bin iggy-server

IGGY_PATH=/tmp/iggy-go \
IGGY_TCP_ADDRESS=127.0.0.1:8090 \
IGGY_HTTP_ENABLED=false IGGY_QUIC_ENABLED=false IGGY_WEBSOCKET_ENABLED=false \
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
target/debug/iggy-server

QUIC, WebSocket and HTTP are enabled by default on ports 8080, 8092 and 3000.
Disable the ones you do not need so they cannot race with another process.

Delivery semantics

SendMessages returns any placements the server reports. Replicated mutations
replay the same frozen request, client identity, and request ID after an uncertain
reply. Once uncertain, only a matching final committed-result proof can settle
the operation. A reconnect binds the existing session, also after a stale client
eviction; a terminal session refusal ends its retained operations and permits a
new registration. While the client registers that new session by itself, a call
that it did not send fails with 58 instead of waiting for the session. The caller
can retry that call. If the registration fails, these calls fail with 40 and the
application must sign in.

A reply with status 30 (StaleClient) can deny one attempt, because the server
no longer attaches the link to the session. Usually the session ended, for
example because an administrator deleted or deactivated the user. But after a
state transfer, which copies the state of another replica, a replica also denies
a live session with 30. So the client closes only that link, as after a stale
client eviction, and the next link binds the session again. If the bind
succeeds, the session keeps its retained operations and its consumer group
membership. If the server refuses the bind, the client registers a new session,
as after any terminal session refusal.

A committed 30 means that the replicated metadata ended the session, so the
client drops the session at once. A committed 30 still settles an uncertain
operation, like any other committed rejection.

The client registers a new session with the WithAutoLogin credentials, or
else with those of the last explicit sign-in. When a password change or a rename
of the user behind those credentials commits, the client updates them. This holds
for typed calls, SendBinaryRequest and ResolveOperation. A rejected change
leaves the credentials unchanged.

If a mutation remains uncertain when its call ends, errors.As exposes
errors.UncertainOutcome with an exact uint64 OperationID. The SDK retains
that request and blocks later mutations on its lane with errors.LaneBlocked
carrying the same ID. Other partitions and pure reads can continue. Metadata
mutations share one lane; partition mutations use the partition incarnation
and partition ID.

Call ResolveOperation(ctx, operationID) to resume that exact request. A proven
business rejection returns its original error and releases the lane. The client
keeps the last maxOperations outcomes that its replays proved, also across a
session reset. So a later ResolveOperation of the same ID returns that outcome
without a request, also after another caller resolved the operation first.

Adding these two methods to contracts.Client requires external implementations,
wrappers, and mocks to implement them. The exported
IggyTcpClient.HandleLeaderRedirection method has been removed; replica routing
and reconnection are managed by the client.

If the application accepts the uncertainty, call
AbandonOperation(ctx, operationID) to release local recovery ownership.
Abandoning does not cancel server execution or make a new application send safe
from duplication. A ResolveOperation that is still running keeps the lane
until it ends, so later mutations still queue behind its replay. The client also
keeps the outcome that this replay proves. errors.OperationNotRetained does not
establish the outcome. Either the client never proved it, because a session
reset or an abandon came first, or the proven outcome aged out. An unknown ID
gets the same error.

tcp.WithPendingOperations(maxOperations, maxBytes) configures the shared bound
for active and retained mutation frames. Both values must be positive. Defaults
are 256 operations and 64 MiB, including each 256-byte header. Admission waits
within the call deadline before allocating a request ID; an oversized frame
fails locally. Writer references keep their bytes charged after abandonment or
session reset until the writer releases them. Calls without an earlier context
deadline have a 30-second operation budget.

Managed connections reconnect independently, with at most four simultaneous
dials and bounded exponential jitter. Each replica connection dials the address
that its node advertises in the cluster roster. With a one-node roster, it dials
the tcp.WithServerAddress endpoint instead, so a single node behind a port
mapping or a port-forward stays reachable. tcp.WithHeartbeatInterval controls
activity on every ready replica connection; the default is five seconds and a
nonpositive interval disables it. Logout, disconnect, and close stop the tasks.

After a session loss, the client registers a new session by itself. A
registration that loses its link or reaches no endpoint goes again after the
reconnect backoff. A refusal by the server, a disconnect or a close stops it.
A sign-in by the application also stops it. If that sign-in fails, the client
signs in again only when the application does. The sign-in does not wait for a
pending automatic registration, and the late reply cannot replace the new
session.

An established session has its own lifetime. Password or PAT changes and PAT
expiry do not end it. Logout, session lease expiry, or user deactivation end the
session and prevent its bind proof from restoring it.

Crash durability follows the topic's durability policy: replicated
confirms replication, while persisted also waits for the required replicas
to persist the message data. An empty confirmation list is a valid success
but does not by itself prove that new messages were appended.

In a cluster, sends, polls, and consumer offset operations use the authoritative
partition route (GetPartitionRoute, command 105). Metadata and membership
operations use the metadata primary. Every replica link binds the same session,
so partition routing preserves consumer-group membership. Route aliases share
view and refusal fences; route and topic caches have bounded capacity.

Use SDKs and servers from the same protocol generation and fresh storage for
this wire-contract cutover. The server refuses the previous storage format;
there is no implicit migration or fallback to retired routing commands.

Next and auto-commit polls can advance progress before replying. A 57 reply,
a lost reply or an eviction after sending ends that call immediately with 57,
releases its transient lane barrier, and creates no retained operation. A later
application poll is a new operation and may skip messages whose progress already
advanced. Pure reads retry within their deadline. Known non-admission replies 58
and 89 can retry without changing the frozen request. Manual offset writes are
replicated mutations and use the exact-ID recovery API above.

Testing

Unit tests need nothing running:

bash
go test ./...

The end-to-end suite runs against a server at the address in
IGGY_TCP_ADDRESS and skips when that variable is unset:

bash
IGGY_TCP_ADDRESS=127.0.0.1:8090 go test ./tests

Add IGGY_TCP_TLS_ENABLED=true to run the TLS cases against a server started
with IGGY_TCP_TLS_ENABLED=true and the certificate pair in core/certs.

From the repository root, the integration harness builds a three-node fixture,
seeds messages, moves metadata leadership independently of the partition
primary, and runs the Go regression. It requires Go on PATH:

bash
cargo build --bin iggy-server --bin iggy
cargo test -p integration given_split_primaries_when_go_group_ -- --ignored --test-threads=1

To use an existing fixture, set its coordinator address and topic. The test
expects eight messages per partition by default; override that with the
positive integer IGGY_POLL_ROUTING_MESSAGES_PER_PARTITION when needed:

bash
IGGY_TCP_ADDRESS=127.0.0.1:20016 \
IGGY_POLL_ROUTING_STREAM=sdk-primary-routing \
IGGY_POLL_ROUTING_TOPIC=go \
go test ./tests -run TestE2E_SplitPrimaryPollsPreserveCoordinatorMembership

Contributing

Before creating a pull request, please run golangci-lint and fix any reported lint issues:

shell
golangci-lint run

Overview (`foreign/java/README.md`)

Java SDK for Apache Iggy

Official Java client SDK for Apache Iggy message streaming.

This is part of the Apache Iggy monorepo. For the main project, see the root repository.

Installation

These examples target server 0.9.0. SDK 0.9.0 is on Maven Central and works with server 0.9.0. The older 0.8.0 artifact speaks the previous TCP protocol and does not work with server 0.9.0. Java 17 or newer is required.

Add the dependency to your project:

Gradle:

gradle
implementation 'org.apache.iggy:iggy:0.9.0'

Maven:

xml
<dependency>
    <groupId>org.apache.iggy</groupId>
    <artifactId>iggy</artifactId>
    <version>0.9.0</version>
</dependency>

See Maven Central for all published versions.

Snapshot Versions

Development builds of the next release carry the version 0.9.1-SNAPSHOT. Get them from the ASF snapshot repository:

Gradle:

gradle
repositories {
    mavenCentral()
    maven {
        url = uri("https://repository.apache.org/content/repositories/snapshots/")
    }
}

dependencies {
    implementation 'org.apache.iggy:iggy:0.9.1-SNAPSHOT'
}

Maven:

xml
<repositories>
    <repository>
        <id>apache-snapshots</id>
        <url>https://repository.apache.org/content/repositories/snapshots/</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
</repositories>

<dependencies>
    <dependency>
        <groupId>org.apache.iggy</groupId>
        <artifactId>iggy</artifactId>
        <version>0.9.1-SNAPSHOT</version>
    </dependency>
</dependencies>

Unreleased 0.11.1 protocol

The source checkout uses protocol 0.11.1. Build it with a matching server.
CommandCode.System.ATTACH_CONSUMER_SESSION (code 14) was removed. Use
CommandCode.System.BIND_SESSION (code 15), which requires the registered
session's private bind secret. Auxiliary poll connections reuse that session
instead of logging in independently.

Transport loss retains the logical identity and reconnect uses BindSession.
A definitive stale or unauthenticated bind reply permits a fresh Register, and
transient replies retain the identity. A stale-client eviction during the bind
is a lost connection, so the next connection binds the same session again. An
unauthenticated eviction during the bind ends the session. The bind proof is an
independent session credential: password/PAT changes or expiry do not end an
established session. Logout, lease expiry and user deactivation end session
access.

A reply with status 30 (STALE_CLIENT) means that the server no longer attaches
the connection to the session. The client closes that connection, and the next
one binds the session again. A live session keeps its retained operations and
its group membership. An ended session gets a refused bind, and the client signs
in again with the remembered sign-in or stays signed out.

RetryPolicy limits one burst of redials after a connection loss, not the life
of the client. A heartbeat that fails starts a new burst with a fresh budget. If
the client lost its session and every sign-in of a burst lost its link, the
burst ends with the client signed out. A heartbeat that the server answers then
signs in again with a fresh budget. A logout, a rejected sign-in and close()
end the automatic sign-in. With RetryPolicy.noRetry(), the client stays signed
out until the caller signs in.

An explicit sign-in ends the automatic sign-in as soon as it starts. It does not
wait for an automatic Register whose reply is late, and that reply cannot
replace the new session. If the explicit sign-in succeeds, it becomes the
automatic sign-in. If it fails, the client does not sign in again as the
previous user.

A committed password change or rename of the signed-in user updates the
credentials of the automatic sign-in. It also updates the builder credentials
that login() uses when they name that user. This holds for typed calls,
sendBinaryRequest and resolveOperation. A rejected change leaves the
credentials unchanged.

Quick Start

Iggy.tcpClientBuilder() handles the routing and the session for you. Over TCP and TCP/TLS, the client keeps the consumer group membership on the coordinator. It maintains one shared-session connection per advertised replica and multiplexes partition requests on those connections. With a one-node roster, the client dials the configured endpoint instead of the advertised one. This keeps a single node behind a port map or a port-forward reachable.

Start the server with the example prerequisites and matching credentials. The following snippets show alternative clients. Close a blocking client with close() or an async client with close().join() when finished.

TCP Client (Blocking)

java
import org.apache.iggy.Iggy;

// Create and connect with auto-login
var client = Iggy.tcpClientBuilder()
    .blocking()
    .host("localhost")
    .port(8090)
    .credentials("iggy", "iggy")
    .buildAndLogin();

// Or build, connect, and login separately
var client = Iggy.tcpClientBuilder()
    .blocking()
    .host("localhost")
    .port(8090)
    .build();
client.connect();
client.users().login("iggy", "iggy");

Connection String

fromConnectionString configures a TCP client from an iggy:// connection string:

java
// Blocking client
var client = Iggy.tcpClientBuilder()
    .fromConnectionString("iggy://iggy:iggy@localhost:8090")
    .buildAndLogin();

// Async client, with TLS and a custom CA certificate
var asyncClient = Iggy.tcpClientBuilder()
    .async()
    .fromConnectionString("iggy+tcp://admin:[email protected]:8090?tls=true&tls_ca_file=/certs/ca.pem")
    .buildAndLogin()
    .join();

The connection string accepts iggy:// and iggy+tcp:// with : credentials and the
tls and tls_ca_file options, each at most once. tls_ca_file requires tls=true, and a string without tls
keeps the TLS setting already on the builder. Personal access tokens and other options are not supported yet
and are rejected with an IggyInvalidArgumentException.

TCP Client (Async)

java
import org.apache.iggy.Iggy;

// Create async client
var asyncClient = Iggy.tcpClientBuilder()
    .async()
    .host("localhost")
    .port(8090)
    .credentials("iggy", "iggy")
    .buildAndLogin()
    .join();

// Or with manual connect and login
var asyncClient = Iggy.tcpClientBuilder()
    .async()
    .host("localhost")
    .build();
asyncClient.connect().join();
asyncClient.users().login("iggy", "iggy").join();

HTTP Client

java
import org.apache.iggy.Iggy;

// Using URL
var httpClient = Iggy.httpClientBuilder()
    .blocking()
    .url("http://localhost:3000")
    .credentials("iggy", "iggy")
    .buildAndLogin();

// Using host/port
var httpClient = Iggy.httpClientBuilder()
    .blocking()
    .host("localhost")
    .port(3000)
    .credentials("iggy", "iggy")
    .buildAndLogin();

TLS Support

Both TCP and HTTP clients support TLS:

java
// TCP with TLS
var secureClient = Iggy.tcpClientBuilder()
    .blocking()
    .host("iggy-server.example.com")
    .port(8090)
    .enableTls()
    .tlsCertificate("/path/to/ca.pem")  // Optional custom CA
    .credentials("admin", "secret")
    .buildAndLogin();

// HTTPS
var secureHttpClient = Iggy.httpClientBuilder()
    .blocking()
    .host("iggy-server.example.com")
    .port(443)
    .enableTls()
    .credentials("admin", "secret")
    .buildAndLogin();

Builder Options

The client builders support additional configuration:

java
var client = Iggy.tcpClientBuilder()
    .blocking()
    .host("localhost")
    .port(8090)
    .connectionTimeout(Duration.ofSeconds(10))
    .requestTimeout(Duration.ofSeconds(30))
    .retryPolicy(RetryPolicy.exponentialBackoff())
    .credentials("iggy", "iggy")
    .buildAndLogin();

Event Loop Threads

Each TCP client shares one event loop group across its replica connections. By default
the group has one thread. An application that opens many clients can instead register them all on
one caller-owned group. The clients never shut that group down. Close the clients first,
then shut the group down:

java
var group = new MultiThreadIoEventLoopGroup(2, NioIoHandler.newFactory());

var producer = Iggy.tcpClientBuilder()
    .blocking()
    .eventLoopGroup(group)
    .credentials("iggy", "iggy")
    .buildAndLogin();
var consumer = Iggy.tcpClientBuilder()
    .blocking()
    .eventLoopGroup(group)
    .credentials("iggy", "iggy")
    .buildAndLogin();

// ... later
producer.close();
consumer.close();
group.shutdownGracefully();

Do not block in a completion callback. Callbacks run on the group's loops, so a blocked
callback stalls every client that shares the group.

Version Information

java
// Get SDK version
String version = Iggy.version();  // e.g., "0.9.0"

// Get detailed version info
IggyVersion info = Iggy.versionInfo();
info.getVersion();     // Version string
info.getBuildTime();   // Build timestamp
info.getGitCommit();   // Git commit hash
info.getUserAgent();   // User-Agent string for HTTP

Exception Handling

The SDK's custom exception types inherit from IggyException. When you join a failed future, the cause can come back inside a CompletionException. The HTTP client's close() method declares IOException. Handle these two boundaries as well as the specific SDK errors.

Retained TCP operations

Replicated mutations serialize within their metadata or partition lane. If a sent mutation's
outcome cannot be established, IggyOperationException carries
IggyErrorCode.OPERATION_UNCERTAIN and its exact operation ID. A later mutation in the same
lane returns LANE_BLOCKED with that ID. Unrelated partitions and pure reads continue.

Only a reply that refuses a sent mutation, or a committed result for it, settles it. A lost
connection, an eviction or a malformed reply leaves it uncertain. After a lost connection, a
stale-client eviction or a malformed reply, the call replays the same request on a new
connection within its deadline. If the server refuses to bind the session again, the client
registers a new session and drops the operation.

An eviction and a reply can carry the same status, for example 30 (STALE_CLIENT) or 40
(UNAUTHENTICATED). Only the eviction is a lost connection, so the call does not replay a
request that a reply refused. A refused first attempt ends with that error. After an earlier
sent attempt without a proved reply, the call ends with OPERATION_UNCERTAIN.

Both blocking and async TCP clients expose resolveOperation(operationId, timeout) and
abandonOperation(operationId). Resolve reuses the original request bytes and returns the
response body, or the proved business error. A resolve that finds a replay of the same operation
running waits for it within its own timeout. If that wait runs out, the resolve fails with
OPERATION_UNCERTAIN. Java long preserves all 64 operation-ID bits, including negative values.
When you display an ID, use Long.toUnsignedString(operationId).

The client keeps the proved outcomes of its last replays, up to the operation count of
.pendingOperations. A later resolve returns the same outcome without a request, also after a
session reset. Abandon releases local retention and frees the pending operation slot at once. It
does not cancel or undo server work. A running replay keeps the lane until the replay ends, so a
later mutation in that lane waits for it. That replay still keeps the outcome that it proves.
OPERATION_NOT_RETAINED provides no evidence that the original mutation was uncommitted. It
means that a session reset or an abandon came before a proved outcome, or that the proved outcome
aged out. Do not resend it automatically.

The TCP builders accept .pendingOperations(count, bytes), defaulting to 256 mutations
and 64 MiB across active and retained frames. Admission waits within the call's existing
deadline; an oversized frame fails locally. Cancellation removes only the caller's waiter.
A mutation already sent may remain retained. The byte limit counts admitted frozen frames and
writer references; API input snapshots and queued serialization buffers are outside this limit.

Auto-commit and Next polls use a temporary partition ordering barrier. Their success
acknowledges local admission, not replicated offset durability. Code 57, a lost or malformed
reply, or an eviction ends the call with code 57. Progress is unknown, and the call
releases the barrier. These polls are never retained or replayed after uncertainty. Explicit non-admission
(58 or 89) may retry within the same deadline.

Examples

See the Java Examples directory for runnable applications demonstrating the SDK:

  • GettingStartedProducer: synchronous message production with batch sending
  • GettingStartedConsumer: synchronous consumption with polling loops
  • AsyncProducer: non-blocking batch production with concurrent request submission
  • AsyncConsumer: async consumption with backpressure and error recovery

The examples README describes blocking and async clients, CompletableFuture patterns, and thread pool management.

For Apache Flink integration, see the Flink Connector Library.

Building from Source

This project uses the Gradle Wrapper. Due to Apache Software Foundation policy, the gradle-wrapper.jar binary is not checked into the repository. Instead, the gradlew script automatically downloads it on first run.

bash
# Build the project
./gradlew build

# Run tests
./gradlew test

The wrapper script will:

  1. Download gradle-wrapper.jar from the official Gradle repository if missing
  2. Verify the SHA256 checksum for security
  3. Execute the requested Gradle command

No manual Gradle installation is required.

Note: Only the Unix shell wrapper (gradlew) is provided. On Windows, use WSL or Git Bash, or install Gradle manually.

Contributing

Before opening a pull request:

  1. Format code: ./gradlew spotlessApply
  2. Validate build: ./gradlew check
  3. Use AssertJ for assertions: Write test assertions with AssertJ (assertThat(...)) instead of JUnit assertions.

These steps keep the code style compliant and make sure that all tests and checkstyle validations pass.


Overview (`foreign/node/README.md`)

Apache Iggy Node.js Client

Apache Iggy Node.js client written in typescript, it currently only supports tcp & tls transports.

Disclaimer: although all iggy commands & basic client/stream are implemented this is still a WIP, provided as is, and has still a long way to go to be considered "battle tested".

note: This lib started as iggy-bin ( github / npm) before migrating under iggy-rs org. package [email protected] is equivalent to @iggy.rs/[email protected] and migrating again under apache iggy monorepo ( github and is now published on npmjs as apache-iggy

note: previous works on node.js http client has been moved to iggy-node-http-client (moved on 04 July 2024)

install

bash
npm i --save apache-iggy@edge

Use matching SDK and server builds from the coordinated VSR release. Older builds are incompatible. This release requires fresh server storage (IGGY-BOUND-GRANTS-4). Existing incompatible data is refused unchanged. For a local server with the example credentials, follow the example setup.

basic usage

Response frame limit

Compatibility note: response frames larger than maxResponseFrameSize (default 64 MiB) are rejected and close the connection. Raise the limit in the client configuration when polling very large batches.

VSR framing

The SDK speaks the VSR wire protocol exclusively and requires an Iggy VSR
server:

typescript
import { SimpleClient, getRawClient } from "apache-iggy";

const config = {
  transport: "TCP" as const,
  options: { host: "127.0.0.1", port: 8090 },
  credentials: { username: "iggy", password: "iggy" },
};
const client = new SimpleClient(getRawClient(config));
const stats = await client.system.getStats();

Codes absent from the SDK command table use Operation::NonReplicated and
carry the command code in the request header's reserved field. The server
remains authoritative for classifying or rejecting extension commands.

Sends always carry an explicit partition id. The client resolves
Partitioning.Balanced round-robin and Partitioning.MessageKey as XXH32
(seed 0) of the key modulo the topic's partition count, as the Rust SDK does.
The count is cached per topic. The client reads it again after a refused send,
and after the client deletes a stream or a topic or creates or deletes
partitions. A raw SendMessages command without an explicit partition id
fails with error code 5 before the client sends it.

Partition commands carry a context containing the partition incarnation, owner
generation and metadata operation. Pass the context of an earlier poll reply to
keep an offset write or continuation tied to that history. Otherwise the client
captures the route context. Non-admission retries and uncertain-operation replay
preserve the first captured context, request identity and payload. Route refreshes
change the destination. Lifecycle error 88 retries with a fresh ID and the same
context. A send without caller-supplied context can refresh history once after
error 87; explicit contexts return that error. A new call can capture newer
metadata progress.

Route discovery uses command 105. Sends, polls and offset reads/writes use the
partition primary; other commands use the metadata primary. One logical client
owns a shared session, a monotonic request counter and one multiplexed link per
replica. Healthy links survive primary changes. Each link dials the address
that its node advertises in the cluster roster. With a one-node roster, the
link dials the configured endpoint instead. A single node behind a port
mapping or a port-forward thus stays reachable. The legacy poolSize option
accepts only one logical client; it does not limit replica links or concurrency.
The public Client._pool property has been removed. Applications should use the
client methods directly; the client owns its replica connections.

VSR authentication translates password and personal-access-token login into the
Register handshake. Replacement links bind the existing session. Confirmed
session expiry requires a new Register; an uncertain mutation is never replayed
under that new session. An eviction that ends the session does not prove the
outcome of a mutation that the client already wrote. That call ends with
OperationUncertainError. A stale client eviction drops only its link, because
the server keeps the session. The client binds the same session on a new link,
as after any lost link, so retained operations and group membership stay.
Explicit relogin logs out the previous session first.

A reply with status 30 (StaleClient) says that the server no longer attaches
the link to the session. This happens, for example, after the user was deleted
or deactivated. A 30 refusal before commit fails a first attempt with that
error. After an earlier written attempt, it does not prove the outcome, so that
call ends with OperationUncertainError. The client closes that link and binds
the session on a new link, as after any lost link. A replica that replaced its
client table refuses a live session the same way, so the bind decides. A live
session stays, with its retained operations and group membership. An ended
session refuses the bind, and the client signs in again.

A committed 30 comes from the replicated session table, so that session ended.
It is a proved outcome, like any other committed rejection, and the client
drops the session at once and signs in again.

Mutation recovery and limits

Mutations share one FIFO lane per partition history, with a separate metadata
lane. An uncertain mutation remains retained and blocks later mutations in its
lane. OperationUncertainError.operationId and LaneBlockedError.operationId
are exact bigint values. Use resolveOperation(operationId, { deadline }) to
replay the retained frame, where deadline is an absolute Date.now() timestamp
in milliseconds. The default operation budget is 30 seconds, including admission,
routing, link waits and retries.

Resolution requires correlated committed-result proof. A proved business
rejection returns the original error and releases the lane. An unproved denial
keeps an uncertain operation retained. The client keeps the proved outcomes of
its last maxPendingOperations replays, also across a session reset. If a
resolver already proved the outcome, a later resolveOperation of the same ID
returns it without a request. A resolveOperation call that waits for another
replay of the same ID and reaches its deadline fails with
OperationUncertainError.

abandonOperation(operationId) releases the client lane without proving that
the mutation failed. A replay that is already running keeps the lane until it
ends, and the client keeps the outcome that it proves. Logout, session reset and
destruction discard retained operations with their outcomes still unknown.
OperationNotRetainedError does not establish the outcome: a reset or an
abandon came before a proved outcome, or the proved outcome aged out.

Breaking change: Client.destroy() aborts in-flight calls immediately and
discards retained operations. It does not wait for outstanding calls to finish.
Await those calls before destroying the client when their results are needed.

Auto-commit and Next polls hold their partition lane only during the active call.
Error 57 or a lost reply ends the call immediately with unknown progress and no
retained operation ID. These polls never replay after uncertainty. Known
non-admission errors 58/89 remain retryable; pure reads may retry lost replies.
Before uncertainty, lifecycle error 88 retries with a fresh ID within the same
deadline. After uncertainty, only committed-result proof can release the lane.

Configure maxPendingOperations (default 256) and maxPendingOperationBytes
(default 64 MiB) on the client. Admission waits before allocating an ID or freezing
a mutation frame. Both limits must be positive. The byte budget includes the
256-byte header and body; smaller positive budgets reject mutations that cannot
fit while reads and login remain available. Oversized frames fail locally.
Completion, abandonment and reset release the operation slot. Writer aliases keep bytes charged until they release
the frame. These limits cover admitted active/retained frames, not caller buffers,
queued API input snapshots, or read/control bodies. sendBinaryRequest preserves
its immediate payload snapshot before awaiting a custom provider.

An established session has its own lifetime. Password or PAT changes and PAT
expiry do not end it. Logout, session lease expiry, or user deactivation end the
session and prevent its bind proof from restoring it.

The client pings every heartbeatInterval milliseconds, 5000 by default, which
keeps an idle session alive when the server's [heartbeat] eviction is enabled.
heartbeatInterval also accepts a duration expression such as "10s" or
"1h 30m", like the Rust SDK.
With the default heartbeat settings, a client that sends nothing for 36 s
(1.2 x the 30 s interval) stops renewing its session, and the server ends the
session about 36 s later. The verifier checks once per interval and also evicts
the connection of a consumer-group member that is silent past 36 s. If the
client interval exceeds 36 s, the verifier evicts an idle group member. If it
exceeds about 72 s, or is 0 to disable client heartbeats, any idle session
expires. Any other unusable value is rejected instead of silently disabling the
heartbeat.

A replica link redials on its own after it drops, with jittered exponential
backoff. reconnect.maxRetries limits one burst of redials, not the life of
the client. While the client stays signed in, each heartbeat restarts a link
that used its budget, with a fresh budget. A request does not redial a link. It
retries within its deadline until the link returns. destroy() and logout stop
the restarts.

When every link used its budget, a request can fail before its deadline with
NotConnectedError. When no endpoint accepts a dial within its retry budget, a
sign-in also fails with NotConnectedError, and its cause holds the last dial
error. In both cases a later call can succeed, because the heartbeat restarts
the links and each sign-in dials again. If reconnection or heartbeats are
disabled, nothing restarts a link that used its budget, so the request fails
with a plain Error instead.

When a poll fails with status 30, 40, 57, 58 or 89, singleConsumerStream and
groupConsumerStream poll again after interval. They also poll again after a
poll deadline, after a poll with unknown progress and after
NotConnectedError. The client binds the session again, signs in again,
reconnects or routes again after these on its own. Any other error ends the
stream. If the reply of an auto-commit poll is lost, the stream can skip that
batch.

singleConsumerStream and groupConsumerStream sign in before they return the
stream, and groupConsumerStream also joins its group. If the sign-in or the
join fails, the call destroys its client and rejects with that error. If no
endpoint accepts a dial within the retry budget, the error is
NotConnectedError. If the 30 second sign-in deadline ends first, the error is
VsrResponseTimeoutError. With the default reconnect values, the deadline
usually ends first. When a stream closes, it destroys its client.

sendBinaryRequest(code, payload) sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.

typescript
import { ResponseError } from "apache-iggy";

try {
  await client.sendBinaryRequest(60_000, Buffer.from("opaque request"));
} catch (error) {
  if (error instanceof ResponseError) {
    console.error(error.commandCode, error.errorCode);
  }
}

The client includes its npm package version and the binary wire protocol
version in VSR registration. An incompatible server rejects registration with
a protocol-version error instead of accepting a mismatched wire contract.

ts
import { Client } from "apache-iggy";

const credentials = { username: "iggy", password: "iggy" };

const client = new Client({
  transport: "TCP",
  options: { port: 8090, host: "127.0.0.1" },
  credentials,
});

const stats = await client.system.getStats();

Connection strings

Every client constructor (except SimpleClient see note) also accepts a
connection string instead of a config object:

ts
import { Client } from "apache-iggy";

const client = new Client("iggy://iggy:[email protected]:8090");
const stats = await client.system.getStats();

Supported schemes are iggy:// (TCP, default) and iggy+tcp://. Credentials
are username:password or a single personal access token. Options mirror the
other SDKs: tls, tls_domain, tls_ca_file, reconnection_retries,
reconnection_interval, heartbeat_interval, nodelay, max_pending_operations
and max_pending_operation_bytes. reestablish_after
is accepted for format compatibility but has no Node equivalent.

note: SimpleClient does not accept a connection string: it wraps an existing
RawClient instance rather than building one from configuration. Pass the
connection string to Client, SingleClient or getRawClient and hand the
resulting raw client to SimpleClient if needed.

option limits

option limit
reconnection_retries integer up to 4294967295 (u32 max); larger values are rejected like Rust's u32 overflow, and unlimited maps to this ceiling. Defaults to unlimited
heartbeat_interval duration up to 2147483647ms (Node's largest timer delay); 0 disables heartbeats
reconnection_interval positive duration (ms, s, m, h) up to 2147483647ms (Node's largest timer delay); zero spellings are rejected. Defaults to 1s
port in the authority decimal up to 65535

Durations accept the same expressions as the Rust SDK, for example 500ms,
10s, 1h 30m, 5d, 2w, 1y; matching is case-insensitive and
0, unlimited, disabled and none map to zero. Unit-less numbers such as
5 are rejected.

use sources

Cluster polling uses command 15 (BindSession), route discovery 105 and poll
100. Commands 14, 103, 104 and 123 are retired. Pause consumers for the coordinated
server/SDK upgrade and restart them to rejoin their groups. There is no fallback
to retired polling commands.

Install

bash
npm ci

build

bash
npm run build

test

note: use env var IGGY_TCP_ADDRESS="host:port" to set the server
address for e2e tests. bdd tests need more variables, see below.

unit tests

bash
npm run test:unit

e2e tests

e2e test expect an iggy-server at tcp://127.0.0.1:8090

bash
npm run test:e2e

bdd tests

the bdd suite has no defaults and fails when IGGY_TCP_ADDRESS,
IGGY_ROOT_USERNAME or IGGY_ROOT_PASSWORD is missing. from the repository
root run

bash
./scripts/run-bdd-tests.sh node

the script starts the server and sets every variable, so none of them have to be
exported by hand. to iterate against a server you started yourself, see
src/bdd/README.md.

run all test

npm run test runs unit, bdd and e2e tests suite against an iggy-server at
tcp://127.0.0.1:8090, started with the same root credentials

bash
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
  npm run test

lint

bash
npm run lint

Overview (`foreign/php/README.md`)

iggy-php

PHP extension bindings for Apache Iggy, built in Rust with
ext-php-rs.

This repository is experimental. The Rust Iggy SDK is async and Tokio-based, but
this extension exposes Iggy\Client as a blocking synchronous PHP API. Each call
drives the lazy global Tokio runtime and blocks the calling PHP thread until the
future resolves; it does not provide fiber-aware or non-blocking I/O.

Requirements

  • Rust and Cargo
  • PHP 8.3 or newer with php-config (non-thread-safe builds only, no ZTS)
  • cargo-php
  • Composer, for installing PHPUnit
  • Docker, for running the integration test server

On macOS with Homebrew PHP:

sh
export PATH="/opt/homebrew/opt/php/bin:$PATH"
export PHP=/opt/homebrew/opt/php/bin/php
export PHP_CONFIG=/opt/homebrew/opt/php/bin/php-config

Build

sh
cargo build --release

Generate IDE stubs after changing the exported PHP API:

sh
cargo build
cargo php stubs target/debug/libiggy_php.so -o /tmp/iggy-php.stubs.php

Stub generation requires a debug build. On macOS, use libiggy_php.dylib.
Preserve the existing Apache license header when updating iggy-php.stubs.php.
The CI lint job regenerates this file and fails if the checked-in stubs drift
from the Rust signatures.

Install

sh
cargo php install --release --yes

If the extension is already enabled, reinstall it with:

sh
cargo php remove --yes
cargo php install --release --yes

Verify PHP can load it:

sh
php -r 'var_dump(extension_loaded("iggy-php"));'

Run Iggy

Use Iggy 0.9.0. When testing unreleased SDK changes, build the server from the
same source checkout.

sh
docker run --rm --name iggy-php-test \
  --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \
  -p 8090:8090 \
  -p 3000:3000 \
  -e IGGY_TCP_ADDRESS=0.0.0.0:8090 -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 \
  -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \
  -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
  apache/iggy:0.9.0

You can also run a local server from the repository root:

sh
cargo run --bin iggy-server -- --fresh --with-default-root-credentials

The root variables bootstrap a new data directory; they do not replace stored
credentials. Environment credentials override --with-default-root-credentials.
Use --fresh only with disposable development data: it deletes local replica state.

The tests assume:

  • host: 127.0.0.1
  • port: 8090
  • username: iggy
  • password: iggy

Override them with IGGY_TCP_ADDRESS, IGGY_ROOT_USERNAME, and IGGY_ROOT_PASSWORD.

Usage

php
<?php

$client = new \Iggy\Client('127.0.0.1:8090');
$client->connect();
$client->loginUser('iggy', 'iggy');

$stream = 'php-stream';
$topic = 'php-topic';
$partitionId = 0;

$client->createStream($stream);
$client->createTopic($stream, $topic, 1, null, null, null, null);

$client->sendMessages($stream, $topic, $partitionId, [
    new \Iggy\SendMessage('hello from PHP 1'),
    new \Iggy\SendMessage('hello from PHP 2'),
]);

$messages = $client->pollMessages(
    $stream,
    $topic,
    $partitionId,
    \Iggy\PollingStrategy::first(),
    10,
    true,
);

foreach ($messages as $message) {
    echo $message->payload(), PHP_EOL;
}

Consumer group callbacks require a finite message limit. The partition id
argument is ignored for a consumer group, since the member reads the partitions
the server assigns to it. After the example above, each loop below consumes one
of its two messages:

php
<?php

$consumer = $client->consumerGroup(
    'php-consumer',
    $stream,
    $topic,
    null,
    \Iggy\PollingStrategy::next(),
    10,
    \Iggy\AutoCommit::disabled(),
    true,
    true,
    1_000_000,
    null,
    null,
    null,
    false,
);

$consumer->consumeMessages(
    function (\Iggy\ReceiveMessage $message) use ($consumer): void {
        echo $message->payload(), PHP_EOL;
        $consumer->storePosition($message->position());
    },
    1,
);

foreach ($consumer->iterMessages() as $message) {
    echo $message->payload(), PHP_EOL;
    $consumer->storePosition($message->position());

    break;
}

Tests

Run the Dockerized integration suite:

sh
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from php-tests

Run the PHP test suite:

sh
composer install
composer test

Run Rust verification with a matching PHP embedding library (libphp) available
to the linker and dynamic loader:

sh
cargo test --features ext-php-rs/embed

TLS tests are opt-in because they require a TLS-enabled Iggy server and certificate
setup. Set IGGY_TLS_CONNECTION_STRING to enable TLS connection tests. Set
IGGY_TLS_PLAINTEXT_ADDRESS to run the negative plaintext-to-TLS test.

TLS connection strings use the Rust SDK connection-string format, for example:

text
iggy+tcp://iggy:[email protected]:8090?tls=true&tls_domain=localhost&tls_ca_file=/path/to/ca.pem

API Notes

  • new Iggy\Client($address, new Iggy\PendingOperationsConfig(256, 64 * 1024 * 1024))
    configures client-wide active/retained mutation limits. Both limits must be positive;
    omission uses 256 operations and 64 MiB of frozen headers and bodies. Admission waits
    within the operation deadline before assigning an ID. Queued writer references keep
    bytes charged after abandonment or reset until their final owner releases the frame.
    TCP, QUIC and WebSocket connection strings accept max_pending_operations and
    max_pending_operation_bytes, for example
    iggy+quic://iggy:iggy@localhost:8080?max_pending_operations=256&max_pending_operation_bytes=67108864.
  • Iggy\Exception\PendingOperationException preserves the read-only operation_id
    decimal string and kind (uncertain, lane_blocked, or not_retained).
    getCode() returns the SDK error code. Keep IDs as strings: PHP integers cannot
    represent the full unsigned 64-bit range. lane_blocked identifies an earlier
    operation, and the blocked call was not sent. not_retained does not prove
    that the earlier operation failed to commit.
  • Iggy\Client::resolveOperation(string $operation_id, ?int $timeout_micros = null)
    replays that retained operation, with a positive timeout (30 seconds by default).
    A proved committed business rejection raises its original exception and frees the
    lane. An uncertain result keeps the operation retained for another resolution attempt.
    abandonOperation(string $operation_id) releases it without proving whether it
    committed. Both methods reject malformed or overflowing IDs. HTTP does not support
    retained-operation recovery. A session reset, disconnect or shutdown discards
    retained operations. The client keeps the proved outcomes of its last
    max_pending_operations replays, also across a session reset. A later
    resolveOperation() of a proved operation returns the same outcome without a request.
  • Methods are exposed to PHP as camelCase, for example createStream() and
    pollMessages().
  • Classes live in the Iggy namespace, for example Iggy\Client and
    Iggy\SendMessage.
  • Partition IDs use the Iggy partition index. For a topic with one partition, use 0.
  • Passing null as the partition to storeOffset() or deleteOffset() uses the
    current consumer partition, and is rejected until at least one message has been
    polled. Pass an explicit partition id before the first poll.
  • consumeMessages() requires an explicit finite limit. It does not run forever
    by default.
  • iterMessages() returns a PHP Iterator and can be used with foreach.
    Break out of the loop when the caller's processing limit or shutdown signal is reached.
  • AutoCommit::when() may queue an offset commit before the PHP callback runs.
    If callback success must control commits, use AutoCommit::disabled() and call
    storePosition($message->position()) after the callback work succeeds.
  • Every received message keeps the partition context of the poll that delivered it:
    the partition incarnation, which changes when the partition is deleted and recreated,
    and the consumer group owner generation. Iggy\Consumer::storePosition() commits a
    message's position() under that context, so a commit after a recreation or an owner
    change is refused instead of landing on the newer partition. storeOffset() commits
    under the context of the latest message consumed from that partition.
  • Iggy\PollingStrategy::withContext() continues a poll at an offset under the context
    that produced it, for example
    PollingStrategy::offset($message->offset() + 1)->withContext($message->context()).
    A stale context fails the poll. To continue in another process, save the context's
    incarnation, owner_generation and metadata_op with the offset and rebuild it with
    new Iggy\PartitionContext(...).
  • A poll or offset write without a caller context uses the context of its route. It can
    fail once with HistoryUnavailable (87), or ConsumerGroupPartitionNotOwned (5009)
    for a consumer group, after another client deleted and recreated the partition. The
    failed route is dropped, and the next call routes again. Both errors surface as
    Iggy\Exception\IggyException.
  • Iggy\PollingStrategy::timestamp() and Iggy\PollingStrategy::timestampMicros()
    expect microseconds since the Unix epoch. Use
    Iggy\PollingStrategy::timestampSeconds() for PHP time() values.
  • PHP strings are passed as named identifiers, including strings that contain
    only digits. PHP integers are passed as numeric identifiers.
  • Iggy\SendMessage::payload and Iggy\ReceiveMessage::payload() copy the payload
    bytes into a PHP string on each read. Cache large payloads in PHP if they will
    be read repeatedly.
  • Message IDs and checksums are returned as decimal strings. Offset and timestamp
    getters return PHP integers and are limited to PHP_INT_MAX.
  • Iggy\Client::sendBinaryRequest(int $code, string $payload): string sends a
    command code and payload and returns the raw response body.
  • Iggy\Client is synchronous and blocks the current PHP thread.
  • The extension owns a lazy global Tokio runtime. Do not call pcntl_fork() after
    the first Iggy SDK call; the child process inherits file descriptors but not
    Tokio worker threads. Runtime initialization failure is unrecoverable and aborts
    extension use.

Overview (`foreign/python/README.md`)

apache-iggy

Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second.

Installation

Basic Installation

bash
# Using uv in an existing project
uv add apache-iggy

# Using pip
python3 -m venv .venv
source .venv/bin/activate
pip install apache-iggy

Prerequisites

  • Python 3.11+

Published wheels include the Rust extension; installing a wheel does not require
Rust. Building from source and running the development checks below also requires:

  • Rust toolchain: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • uv: curl -LsSf https://astral.sh/uv/install.sh | sh
  • All checks tooling from CONTRIBUTING.md.
  • Docker

Use an SDK release compatible with your server. For unreleased changes, build
the SDK and server from the same source checkout.

Local Development

IMPORTANT: All commands are supposed to be ran from foreign/python unless it's specified to run in repository's root folder.

  1. Build a project for development

    With uv:

    bash
    # Create a venv
    uv venv
    
    # Sync the environment without updating it
    uv sync --frozen --all-extras --no-install-project
    
    # Build the project -- this builds the rust extension into the venv (debug profile) - re-run after any rust change
    uv run --no-sync maturin develop

    With pip:

    bash
    # Create a venv
    python3 -m venv .venv
    
    # Activate the venv
    source .venv/bin/activate
    
    # Install the dependencies
    pip install -e ".[all]"
    
    # Build the project -- this builds the rust extension into the venv (debug profile) - re-run after any rust change
    maturin develop
  2. Run the server to be able to run the tests (this blocks the terminal - run steps 3-5 in a separate one). --fresh deletes local_data/ on every run - drop it if you have existing data you want to keep.

    bash
    # run from the repository's root directory
    cargo run --bin iggy-server -- --with-default-root-credentials --fresh
  3. Run the tests

    uv:

    bash
    uv run --no-sync pytest tests/ -v

    pip:

    bash
    pytest tests/ -v # make sure iggy-server is running and the venv is activated
  4. To update the stubs, after changing the pyo3 API surface, use

    bash
    # run from foreign/python
    cargo run --bin stub_gen
  5. Before committing, test the pre-commit and pre-push hooks. prek only inspects staged content, so stage your work first:

    bash
    git add -A
    prek run # runs pre-commit hooks
    prek run --hook-stage pre-push
    # if a hook modifies files, re-run `git add -A` and `prek run`.

    These are some of the essential commands prek is running, so it's recommended to run them manually before
    running prek / committing / pushing. This list is not exhaustive and other hook failures are possible.

    bash
    uv run --no-sync ruff format .
    bash
    uv run --no-sync ruff check --fix .
    bash
    cargo fmt --manifest-path Cargo.toml
    bash
    cargo clippy --manifest-path Cargo.toml --all-targets --all-features -- -D warnings
    bash
    # run from the repository's root directory
    ./scripts/ci/markdownlint.sh --fix foreign/python/README.md # read the diff after applying this, sometimes it gives unwanted results, e.g. messing up enumerations

Client Configuration

IggyClient takes a server address, a TcpConfig, a QuicConfig, an
HttpConfig, or a WebSocketConfig:

python
import asyncio
from datetime import timedelta

from apache_iggy import AutoLogin, IggyClient, TcpConfig, TcpReconnectionConfig


async def main():
    client = IggyClient(
        TcpConfig(
            server_address="127.0.0.1:8090",
            auto_login=AutoLogin.username_password("iggy", "iggy"),
            reconnection=TcpReconnectionConfig(
                enabled=True,
                max_retries=10,
                interval=timedelta(seconds=2),
                reestablish_after=timedelta(seconds=30),
            ),
            heartbeat_interval=timedelta(seconds=5),
            # tls_enabled=True,
            # tls_domain="localhost",
            # tls_ca_file="../../core/certs/iggy_ca_cert.pem",
            # tls_validate_certificate=True,
            # nodelay=True,
        )
    )
    await client.connect()


asyncio.run(main())

IggyClient(...) also accepts a QuicConfig for the QUIC transport, an
HttpConfig for the HTTP transport, and a WebSocketConfig for the WebSocket
transport. examples/python/getting-started/producer.py shows each swap in
context.

Established binary sessions survive transport loss. Password/PAT changes and
PAT expiry do not end these sessions; logout, session lease expiry and account
deactivation do. Reconnection binds the retained session before attempting a
fresh login.

HttpConfig differs from TCP in two ways. There is no reconnection policy and no
AutoLogin: connect() does not dial over HTTP, but it does start the
heartbeat that heartbeat_interval configures, so call it and then
login_user(...). And HTTP is single-consumer only: the consumer_group(...)
path always fails with Feature is unavailable, at the join by default and at
the returned consumer's first poll if you disable auto_join_consumer_group,
so disabling it is not a workaround. A direct
poll_messages(consumer=Consumer.Group(...)) fails the same way unless you
pass an explicit partition_id, and with one it degrades silently instead: the
consumer kind is not carried on the HTTP wire, so the group is served as an
ordinary consumer named after it, with no membership or partition assignment
behind it. Use Consumer.Single(...) with poll_messages(...). Delivery is
also at-least-once: the default retries=3 replays the full request body, so a
send whose response was lost is applied twice, and only retries=0 opts out.

python
import asyncio

from apache_iggy import HttpConfig, IggyClient


async def main():
    client = IggyClient(HttpConfig(api_url="http://127.0.0.1:3000"))
    await client.connect()
    await client.login_user("iggy", "iggy")


asyncio.run(main())

High-Level Producer

The Python high-level producer API is a port of the Rust high-level producer
API. For detailed producer semantics and configuration guidance, see the
Rust high-level SDK documentation.

Use IggyClient.producer() when an application repeatedly publishes to one
stream and topic. Producer creation is asynchronous because it initializes the
destination before returning. By default, it creates a missing stream and topic,
uses balanced partitioning and sends directly in batches of up to 1,000 messages.
The binary driver retries each chunk within one 30-second deadline, preserving
its request identity. The producer adds no retry under a new identity.

The default mode is direct. Pass BackgroundProducerConfig to queue sends on
background workers instead.

python
import asyncio
from datetime import timedelta

from apache_iggy import DirectProducerConfig, IggyClient, Partitioning, SendMessage


async def main():
    client = IggyClient.from_connection_string("iggy+tcp://iggy:[email protected]:8090")
    await client.connect()

    producer = await client.producer(
        "orders",
        "created",
        partitioning=Partitioning.balanced(),
        mode=DirectProducerConfig(
            batch_length=500,
            linger_time=timedelta(milliseconds=5),
        ),
        create_stream_if_not_exists=True,
        create_topic_if_not_exists=True,
        topic_partitions_count=3,
        topic_message_expiry=None,
        topic_max_size=None,
    )

    async with producer:
        await producer.send_one(SendMessage("order-1"))
        response = await producer.send([SendMessage("order-2"), SendMessage("order-3")])
        print(f"Received {len(response.confirmations)} partition confirmations")


asyncio.run(main())

The producer is bound to the stream and topic passed to producer(). Use
send_with_partitioning(messages, partitioning) to override its partitioning
strategy for one call, or send_to(stream, topic, messages, partitioning) to
send to another existing destination. send_to() does not create or initialize
that destination.

Binary transports retain the original request identity after an uncertain
exchange. Persisted sends also provide crash-safe retry receipts; weaker topic
policies can lose data and receipts on a crash. HTTP NoAck confirms dispatch
only. An application resend creates a new request and can duplicate messages.

Mutation retries belong to the shared client driver. The producer no longer accepts
send_retries or send_retry_interval; passing either raises TypeError. HTTP
keeps its configured HTTP retry policy and does not support operation recovery.

PendingOperationError exposes the exact integer operation_id, the numeric
code, and kind ("uncertain", "lane_blocked", or "not_retained"). On the
same live client, await client.resolve_operation(operation_id, timeout=timedelta(seconds=30)) replays the original operation. A committed
business rejection raises its original error and releases the lane; uncertainty
keeps the operation retained. The default timeout is 30 seconds; it must be a
positive timedelta. await client.abandon_operation(operation_id) releases the
lane without establishing whether the operation committed. A session reset
forgets retained operations, so recovery must use the original session.
The client keeps the proved outcomes of its last max_operations replays, also
across a session reset. A later resolve_operation of a proved operation
returns the same outcome without a request.

TcpConfig, QuicConfig, and WebSocketConfig accept pending_operations= PendingOperationsConfig(max_operations=256, max_bytes=64 * 1024 * 1024). These
client-wide defaults bound active and retained mutations across every partition.
Admission waits before allocating a request ID. Queued writer frames keep their
bytes charged until their last reference is released, even after abandonment.
TCP, QUIC and WebSocket connection strings accept max_pending_operations and
max_pending_operation_bytes as positive integers, for example
iggy+tcp://iggy:iggy@localhost:8090?max_pending_operations=256&max_pending_operation_bytes=67108864.

A failed direct send raises ProducerSendError, which is a RuntimeError
subclass. Its cause property contains the underlying error text, and its
__cause__ preserves a structured PendingOperationError when applicable. The
optional operation_id identifies the retained send to resolve or abandon. Its
committed property contains confirmations from completed chunks, and failed
contains the remaining messages without usable confirmations. For an uncertain
cause, the first chunk of failed may already have committed. Resolve its
operation_id without submitting that chunk as a new send. For lane_blocked,
none of failed was sent: the ID names an earlier operation to resolve before
retrying. Abandonment or a session reset discards retained results, and
not_retained does not prove non-commit. Resubmitting an uncertain chunk can
duplicate messages. Before any safe resubmission, restore payloads changed by
encryption.

Background Mode

A successful background send means the dispatcher accepted the messages. The
worker writes them later, so all four send methods return a
SendMessagesResponse with an empty confirmations list. It does not mean the
server has committed the messages.

python
from datetime import timedelta

from apache_iggy import (
    BackgroundProducerConfig,
    BackpressureMode,
    ProducerSharding,
    SendMessage,
)

producer = await client.producer(
    "orders",
    "created",
    mode=BackgroundProducerConfig(
        num_shards=4,
        linger_time=timedelta(milliseconds=10),
        batch_size=1024 * 1024,
        batch_length=100,
        max_buffer_size=32 * 1024 * 1024,
        failure_mode=BackpressureMode.block_with_timeout(timedelta(seconds=1)),
        max_in_flight=4,
        sharding=ProducerSharding.ORDERED,
    ),
)

async with producer:
    accepted = await producer.send_one(SendMessage("order-1"))
    assert accepted.confirmations == []

Each shard flushes when any configured condition is met: batch_length queued
send calls, batch_size reported bytes, or linger_time since the first send
entered an empty buffer. batch_length counts calls, not individual messages.
Zero disables either batching threshold, while a zero linger flushes as soon as
the worker receives a send.

ProducerSharding.ORDERED hashes the stream/topic destination so its sends use
one sequential worker and retain dispatch order. ProducerSharding.BALANCED
assigns consecutive sends round-robin across the shards for throughput; ordering
for one destination is not guaranteed.

max_buffer_size bounds bytes queued or in flight across the whole producer.
When it is full, BackpressureMode.block() waits indefinitely,
block_with_timeout(duration) waits up to that duration, and
fail_immediately() raises RuntimeError without accepting the batch. A single
batch larger than the whole budget always fails. A zero byte budget is
unlimited. max_in_flight separately bounds concurrent write requests across
all shards; zero uses the runtime maximum.

Binary driver retries and transport reconnection happen inside background workers.
The Python API does not currently expose a background error callback, so a write
that fails at its operation deadline is logged by the Rust SDK and its messages
are dropped from the worker queue. An uncertain chunk remains retained by the
client for recovery; it may already have committed.

See the complete runnable
background_producer.py
example for all background configuration fields and deterministic shutdown.

Cleanup is asynchronous and must be explicit. Prefer async with, as above, so
shutdown runs on both successful and exceptional exits. Otherwise, call
await producer.shutdown() in a finally block. Shutdown waits for active
sends, is safe to call more than once, and rejects later sends with
RuntimeError. In background mode it also drains every queue and flushes all
accepted messages before returning. Object destruction does not perform
asynchronous cleanup; dropping a background producer without shutdown() can
lose buffered messages.

Examples

Refer to the examples/python/ directory for usage examples.

Contributing

See CONTRIBUTING.md for contribution guidelines.

License

Licensed under the Apache License 2.0. See LICENSE for details.


Overview (`foreign/swift/README.md`)

Swift SDK for Iggy

Official Swift client SDK for Apache Iggy message streaming.

The SDK is built up in stages. The client is a TCP connection with optional TLS that
speaks the consensus protocol, signs in, keeps the session alive with a heartbeat,
reconnects, and follows the cluster leader. It manages streams, topics, partitions,
segments, users, personal access tokens, and consumer groups, sends and polls messages,
and stores consumer offsets. The producer and consumer, examples, and BDD scenarios
follow in later changes.

Swift supports the consensus wire protocol and checks commitment proof during
automatic retries. It does not yet expose retained-operation resolve/abandon APIs,
mutation lanes, or pending-operation limits. Those recovery features in the Rust
and other native SDKs are outside the current Swift implementation.

Requirements

  • Swift 6.0 or later (Swift 6 language mode, strict concurrency)
  • macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1, or Linux with a Swift 6 toolchain
  • An Iggy server that speaks the consensus protocol (iggy-server from this repository)

The SDK depends on SwiftNIO,
swift-nio-ssl, and
swift-log.

Installation

Swift Package Manager fetches a package from the root of a git repository, so the SDK
cannot be depended on straight from this monorepo's subdirectory. Add it through the
package mirror, which carries every release as a v<version> tag:

swift
// Package.swift
dependencies: [
    .package(url: "https://github.com/apache/iggy-swift.git", from: "0.1.0")
],
targets: [
    .target(name: "MyApp", dependencies: [.product(name: "Iggy", package: "iggy-swift")])
]

From a checkout of this repository, or a vendored copy, depend on it by path:

swift
dependencies: [
    .package(name: "apache-iggy", path: "../iggy/foreign/swift")
],
targets: [
    .target(name: "MyApp", dependencies: [.product(name: "Iggy", package: "apache-iggy")])
]

Usage

Create a client, connect, and manage resources. With autoLogin set, connect() signs
in and every reconnect restores the session:

swift
import Iggy

let client = IggyClient(address: "localhost:8090", autoLogin: .usernamePassword(username: "iggy", password: "iggy"))
try await client.connect()
let stream = try await client.createStream(name: "orders")
let topic = try await client.createTopic(streamID: "orders", name: "created", options: .init(partitionsCount: 3))
await client.shutdown()

One client owns one connection and is safe to share between tasks: requests are
serialized on the connection. disconnect() closes the connection until the next
connect(), shutdown() is final.

Configuration

ClientConfiguration carries the address, TLS, the credentials to sign in with, the
reconnection policy, the heartbeat interval, and the connect and request timeouts:

swift
var configuration = ClientConfiguration(address: "iggy.example:8090")
configuration.autoLogin = .personalAccessToken(token)
configuration.tls = TLSOptions(caFile: "/etc/iggy/ca.pem")
configuration.reconnection = ReconnectionOptions(maxRetries: 5, interval: .seconds(2))
let client = IggyClient(configuration: configuration)

The server certificate is verified against the system roots, or against caFile when
set, and against the host of the address unless TLSOptions.domain names another one.

Reconnection

A request that hits a dropped connection reconnects, signs in again, and is replayed,
all within its requestTimeout, and only when it provably never reached the log:
reads, pings, and requests the server never admitted. A replicated write whose outcome
is unknown fails with transientNotCommitted instead of being applied twice. A client
left disconnected by a timeout or an outage reconnects on its next call or heartbeat
when it has credentials to sign in with. In a cluster the client reads the roster after
signing in, moves to the leader, and moves on when a node answers that it is not the
primary.

Messages

sendMessages writes a batch to one partition. The server routes explicit partitions
only, so balanced (the default) and messagesKey are resolved on the client:
balanced round-robins over the topic's partitions, and a key is hashed with XXH32 the
way every other SDK hashes it. A message without an id gets a random one, written back
into the batch, so sending the same batch again after transientNotCommitted carries
the same ids. pollMessages reads from a partition, starting where the strategy says:

swift
var batch: [IggyMessage] = ["placed"]
try await client.sendMessages(streamID: "orders", topicID: "created", partitioning: .messagesKey("customer-42"), messages: &batch)
let polled = try await client.pollMessages(
    streamID: "orders", topicID: "created", partitionID: 0, consumer: .consumer(1), strategy: .next, count: 100, autoCommit: true)
for message in polled.messages {
    print(message.offset, message.payloadString)
}

Every partition request carries the partition context the server requires: the
partition's incarnation, which changes when a deleted partition id is reused. The
client reads it from the partition's route and caches it. A poll reply carries the
context it was served under; hand it back with .offset(_:context:) to continue the
same partition, or in a ConsumerPosition to store a checkpoint. The server refuses
either with historyUnavailable once the partition was recreated, and a consumer-group
checkpoint with consumerGroupPartitionNotOwned once the partition moved to another
member.

Consumer groups

A member polls without a partition id and the client takes the next partition of its
assignment, round-robin, syncing the assignment from the server when it changes. The
heartbeat syncs every joined group, so a member picks up new partitions on its own:

swift
try await client.joinConsumerGroup(streamID: "orders", topicID: "created", groupID: "billing")
let batch = try await client.pollMessages(
    streamID: "orders", topicID: "created", partitionID: nil, consumer: .group("billing"), strategy: .next, count: 100, autoCommit: true)

A member holding no partitions gets an empty poll whose partition id is
PolledMessages.noAssignedPartition, and a client that is not a member gets
consumerGroupMemberNotFound. Membership belongs to the session. A reconnect resumes
the session and keeps it, but when the server no longer holds the session the client
signs in under a new one and the caller must call joinConsumerGroup again: the next
group poll fails with consumerGroupMemberNotFound until it does. In a cluster, polls
and offset writes go to the partition's primary over a second connection bound to the
same session.

Errors

Every operation throws IggyError, a value carrying the typed code
(IggyErrorCode, one case per server error code), the raw wire code, and optional
context. A cancelled task gets CancellationError:

swift
func handle(_ error: IggyError) {
    switch error.code {
    case .streamNameNotFound:
        print("create the stream first")
    default:
        print("iggy failed: \(error)")
    }
}

Testing

bash
cd foreign/swift
swift test

The client tests run against an in-process server that speaks the consensus framing
and is scripted per test, so they need no running iggy-server.

The end-to-end suite runs against a server at the address in IGGY_TCP_ADDRESS and
skips when that variable is unset. Start one from the repository root:

bash
cargo build --bin iggy-server
IGGY_PATH=/tmp/iggy-swift IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy target/debug/iggy-server

Then, from foreign/swift:

bash
IGGY_TCP_ADDRESS=127.0.0.1:8090 swift test --filter IggyE2ETests

Add IGGY_TCP_TLS_ENABLED=true to run it against a server started with
IGGY_TCP_TLS_ENABLED=true and the certificate pair in core/certs.

The hash, error-code, model, request, reply, and consensus header tests compare against
Tests/IggyTests/Fixtures/golden.json, a fixture dumped from the Rust protocol crates. Regenerate it after a protocol change, from
foreign/swift, and commit the result; CI fails when the checked-in copy is stale:

bash
cargo run --manifest-path Tools/golden-vectors/Cargo.toml -- Tests/IggyTests/Fixtures/golden.json

Contributing

Format the sources with the toolchain's formatter before opening a pull request; CI runs it
in lint mode and builds with warnings as errors:

bash
swift format format --in-place --recursive Sources Tests Package.swift
swift format lint --strict --recursive Sources Tests Package.swift
swift build -Xswiftc -warnings-as-errors

Overview (`gateways/kafka/README.md`)

Kafka gateway (`iggy-gateway-kafka`)

Foundation layer for apache/iggy#3421: a TCP listener on the Kafka wire port that decodes requests and validates scoped API keys and versions. With a bridge, Produce, Fetch, ListOffsets, Metadata, CreateTopics, DeleteTopics, CreatePartitions, DescribeConfigs, AlterConfigs and IncrementalAlterConfigs use Iggy. InitProducerId and consumer group coordination work with or without one.

Stub warning: with IGGY_KAFKA_BRIDGE_ENABLED=true, Produce, Fetch, ListOffsets, Metadata,
CreateTopics, DeleteTopics, CreatePartitions, DescribeConfigs, AlterConfigs and
IncrementalAlterConfigs use Iggy. With the bridge off (the default), those ten are stubs:
Produce, Fetch and ListOffsets answer retriable NOT_LEADER_OR_FOLLOWER (6), CreateTopics,
DeleteTopics, CreatePartitions, DescribeConfigs, AlterConfigs and IncrementalAlterConfigs answer
NOT_CONTROLLER (41), and Metadata reports every requested topic unknown. CreateTopics,
DeleteTopics, CreatePartitions and both alter APIs run as the bridge's own Iggy user
: with the
bridge on and IGGY_KAFKA_SASL_ENABLED off (the default), any client that can reach this port
can create topics (up to 1000 partitions each), grow them by up to 1000 partitions per topic per
request (100 topics per request), delete topics and their data, or shorten a topic's
retention.ms low enough that the cleaner drops sealed segments, deleting data. With
IGGY_KAFKA_AUTO_CREATE_TOPICS_ENABLED=true and no listener that names principals, any client
that reaches this port can also create topics just by naming them in a Metadata request. When
the connection has a principal (SASL, or a client certificate mapped to an Iggy user),
DeleteTopics, CreatePartitions, AlterConfigs, IncrementalAlterConfigs and Metadata
auto-creation additionally require it to hold a global manage_topics or manage_streams;
without it every resource of a DeleteTopics, CreatePartitions, AlterConfigs or
IncrementalAlterConfigs request, and every missing topic a Metadata request would create,
fails TOPIC_AUTHORIZATION_FAILED (29) and nothing is written, created or deleted.
CreateTopics has no such gate yet - SASL on does not protect it. Every client reads and writes
as the bridge's Iggy user. Iggy's metadata.partitions_max defaults to 0 (unlimited), so set it
before exposing the gateway. See docs/SCOPE.md.

InitProducerId does real work too, with or without the bridge: it allocates a producer id, so a stock idempotent producer starts instead of failing at startup.

Consumer group coordination is not a stub either: FindCoordinator, JoinGroup, Heartbeat,
LeaveGroup, SyncGroup, DescribeGroups and ListGroups are real, with real membership,
rebalances, graceful leave, session expiry and admin views of that membership
(docs/CONSUMER_GROUPS.md). kafka-consumer-groups.sh --list,
--describe --members and --describe --state use those views. The default --describe
calls OffsetFetch (API key 9), which is not implemented, so that mode does not work. With the bridge off,
Metadata reports every topic unknown, so a consumer joins a group and is assigned 0 partitions.
With the bridge on, OffsetCommit and OffsetFetch keep each group's offsets in Iggy, so a group
resumes from its last commit, also after a gateway restart
(docs/OFFSET_STORAGE.md). Every Iggy server must run a build with the
external group consumer kind (#4392) before the first
commit. In a partly upgraded cluster, one commit can make a partition stop committing. In a
cluster, a node that has not applied a group's first commit answers "no offset", so a consumer on
auto.offset.reset=latest can skip records until
#4409.

Run

bash
cargo run -p iggy-gateway-kafka

Default bind: a PLAINTEXT listener on 127.0.0.1:9093. Environment variables:

Variable Default Description
IGGY_KAFKA_LISTENERS unset Kafka-style listener list, PROTOCOL://ip:port entries separated by commas, one per security protocol: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL. An IPv6 address is bracketed: SSL://[::1]:9094. Takes precedence over IGGY_KAFKA_BIND_ADDR, which the image bakes in and which is ignored with a warning. Refused beside IGGY_KAFKA_ADVERTISED_HOST, IGGY_KAFKA_ADVERTISED_PORT (advertise through IGGY_KAFKA_ADVERTISED_LISTENERS instead) and IGGY_KAFKA_SASL_ENABLED (the listener protocols say where SASL runs). See Listeners and TLS
IGGY_KAFKA_ADVERTISED_LISTENERS bind address of each listener PROTOCOL://host:port per listener, the address clients are pointed back at. An IPv6 host is bracketed: SSL://[2001:db8::1]:9094. Required for any listener bound to 0.0.0.0/::
IGGY_KAFKA_BIND_ADDR 127.0.0.1:9093 Single-listener form: TCP address to listen on
IGGY_KAFKA_ADVERTISED_HOST bind IP Single-listener form: hostname/IP clients use to reach this broker (required when binding to 0.0.0.0/::)
IGGY_KAFKA_ADVERTISED_PORT bind port Single-listener form: port advertised in Metadata responses
IGGY_KAFKA_TLS_CERT_FILE unset PEM certificate chain for every SSL and SASL_SSL listener, leaf first. Required when one exists, refused when none does
IGGY_KAFKA_TLS_KEY_FILE unset PEM private key for the certificate (PKCS#8, PKCS#1 or SEC1)
IGGY_KAFKA_TLS_CLIENT_AUTH none Kafka's ssl.client.auth: none, requested or required. Applies to SSL listeners, where a verified client certificate names the Iggy user. A SASL_SSL listener never asks for a client certificate, Kafka's default there. Refused without an SSL listener
IGGY_KAFKA_TLS_CLIENT_CA_FILE unset PEM roots client certificates must chain to. Required with requested or required, refused with none
IGGY_KAFKA_TLS_PRINCIPAL_MAPPING_RULES DEFAULT Kafka's ssl.principal.mapping.rules, turning a client certificate's subject into an Iggy username. Only with client authentication on an SSL listener
IGGY_KAFKA_TLS_RELOAD_INTERVAL_SECS 60 How often the certificate, key and client CA files are re-read; a change goes live for new connections without a restart. 0 never re-reads them
IGGY_KAFKA_MAX_CONNECTIONS 1024 Maximum concurrent connections per listener before new ones are rejected. Each listener has its own allowance, so idle clients on one cannot lock clients out of another. Open connections can therefore reach the number of listeners times this value; size the file-descriptor limit (ulimit -n) for that
IGGY_KAFKA_MAX_FRAME_SIZE 8388608 Maximum accepted request frame size in bytes
IGGY_KAFKA_IDLE_TIMEOUT_SECS 600 Seconds a connection may sit idle before the next frame's length prefix arrives
IGGY_KAFKA_READ_TIMEOUT_SECS 15 Seconds allowed to read a frame body once its length prefix arrives
IGGY_KAFKA_WRITE_TIMEOUT_SECS 10 Seconds allowed to write a response frame
IGGY_KAFKA_SHUTDOWN_DRAIN_TIMEOUT_SECS 25 Seconds graceful shutdown waits for in-flight connections before abandoning them. The bridge then signs its Iggy clients out, 5 s at most
IGGY_KAFKA_INSTANCE_ID 0 This gateway's number among the gateways fronting one Iggy cluster. It is the high half of every producer id InitProducerId hands out, and Kafka requires those to be cluster-unique, so give every gateway its own value. A single gateway can leave it at 0.
IGGY_KAFKA_BRIDGE_ENABLED false Connect the Iggy bridge at startup. While false every API answers with its stub, and the IGGY_KAFKA_IGGY_* variables below are read by nothing. A failed connection is fatal, not a downgrade to stubs.
IGGY_KAFKA_SASL_ENABLED false Single-listener form: require SASL/PLAIN before serving any other API (true or false, nothing else). With IGGY_KAFKA_LISTENERS, a SASL_PLAINTEXT or SASL_SSL listener says the same
IGGY_KAFKA_PRE_AUTH_TIMEOUT_SECS 15 Seconds an unauthenticated connection may sit between frames. Waiting for an authentication slot and the verification itself each get this budget, the verification's starting once it holds a slot. Separate from the 10-minute idle timeout that applies once authenticated
IGGY_KAFKA_MAX_CONCURRENT_AUTHENTICATIONS 4 Credential verifications the gateway runs at once, across all connections. Each costs a password hash on an Iggy shard thread. This bounds the gateway's side only: a check that times out frees its slot while its hash keeps running inside Iggy. Size it below the Iggy node's shard count
IGGY_KAFKA_AUTO_CREATE_TOPICS_ENABLED false Kafka's auto.create.topics.enable: a Metadata lookup of a missing topic creates it (Auto topic creation). Needs IGGY_KAFKA_BRIDGE_ENABLED=true. Off by default, unlike Kafka
IGGY_KAFKA_AUTO_CREATE_TOPICS_PARTITIONS 1 Partitions of an auto-created topic, at most 1000. Only read while auto-creation is on, and refused otherwise

Quick start (Docker Compose)

The fastest way to see a Kafka client talk to Iggy: two containers, no local Rust toolchain to
build them. (Reading the record back below uses docker compose exec, not a local build either.)

text
Kafka wire (TCP 9093)              Iggy wire (TCP 8090)
  kcat / librdkafka  ─────────────────────▶  gateway  ─────────────────────▶  iggy-server
  (your Kafka client)                    iggy-gateway-kafka                  (real broker)
bash
cd gateways/kafka
docker compose up --build

iggy-server pulls the prebuilt apache/iggy:edge image (refreshed on a master push only when server or iggy-cli is affected, or web or core/server/Dockerfile changes),
so only iggy-gateway-kafka compiles from source. Its Dockerfile mounts a cargo registry/target
cache, so a first run costs one real compile and every run after reuses it, even across a source
change elsewhere in the workspace. Testing a local iggy-server change: comment out image: and
uncomment build: in docker-compose.yml first. That build mounts cargo registry, git, and
target caches, so the first run is still a full release build of several minutes and later runs
reuse those mounts.

This starts iggy-server (the real Iggy broker) and iggy-gateway-kafka (bridge enabled,
pointed at that server) on a shared Docker network, with the gateway waiting for the server's
health check before it starts. From another terminal:

bash
# Broker discovery works with just kcat - it has no create-topic mode of its own, though.
kcat -b 127.0.0.1:9093 -L

A full create-topic-then-produce flow needs a client that can send CreateTopics, unless
IGGY_KAFKA_AUTO_CREATE_TOPICS_ENABLED=true (see Auto topic creation). With Python's kafka-python (illustrative - not exercised by an
automated test, so adjust if the library's API has moved since this was written):

python
from kafka.admin import KafkaAdminClient, NewTopic
from kafka import KafkaProducer

admin = KafkaAdminClient(bootstrap_servers="127.0.0.1:9093")
admin.create_topics([NewTopic(name="orders", num_partitions=1, replication_factor=1)])

producer = KafkaProducer(bootstrap_servers="127.0.0.1:9093")
producer.send("orders", b"hello from kafka-python").get(timeout=5)

Read the record back with a real Kafka consumer - Fetch is wired to Iggy with the bridge on (see
the stub warning above), so kcat reads it the same way it would against a real broker:

bash
kcat -b 127.0.0.1:9093 -C -t orders -o beginning -e

-e exits once kcat catches up rather than waiting for more. No consumer group needed for a
one-shot read; see docs/CONSUMER_GROUPS.md for group-based consuming.
Polling Iggy directly still works too, if you want to see the record from the other side:

bash
docker compose exec iggy-server /usr/local/bin/iggy --username iggy \
  --password iggy-gateway-quickstart message poll kafka orders 0 --offset 0 --message-count 1

Ports: gateway 9093 (Kafka wire, the one a client dials), Iggy 8090 (TCP, iggy CLI/SDK),
3000/8080/8092 (Iggy HTTP/QUIC/WebSocket, unused by the gateway itself but published for
direct Iggy inspection).

Config: the compose file sets fixed dev-only root credentials (IGGY_ROOT_PASSWORD) so the
gateway's IGGY_KAFKA_IGGY_PASSWORD is known ahead of time - never reuse them outside a local
stack. See gateways/kafka/docker-compose.yml and the environment variable tables below for
every other knob.

Limitations (see the stub warning above for the full list): single gateway, single Iggy node,
SASL off by default. This is a development quick start, not a production deployment shape. If your
local apache/iggy:edge predates #4392, run
docker compose pull, because that server refuses every offset commit.

This quick start's topics use Iggy's default Durability::Replicated - a Produce ack is a
quorum commit, not an fsync. A docker kill or host power loss can lose acked records despite
the named Docker volume; the volume protects a clean docker compose down/up, not a crash.
Neither this quick start nor phase1_e2e_tests.rs exercises crash durability - the automated
test and the manual read-back above both only prove a produced record is readable from the
still-live process that wrote it. docs/MANUAL_TESTING.md's Category I covers a restart (a
graceful docker compose restart, which flushes on the way down) as a smoke test for "does the
volume actually persist across a container restart" - it is not a crash test and does not
contradict this limitation.

Success criteria (tracked for #3539): a new
contributor should reach a successful produce (and an Iggy-side read-back confirming it landed)
within 15 minutes of git clone, and every Phase 1 API key (CreateTopics, Metadata, Produce,
ListOffsets) has an automated test exercising the real compiled binaries end to end -
tests/phase1_e2e_tests.rs is that test, spawning real iggy-server and iggy-gateway-kafka
processes (not KafkaGateway::run in-process, as every other suite in this crate does) and
driving CreateTopics → Metadata → Produce → ListOffsets over real TCP, then reading the produced
records back through the Iggy SDK.

Test

bash
cargo test -p iggy-gateway-kafka

See docs/TEST_SUITE.md for the full suite catalog (cargo test -p iggy-gateway-kafka -- --list for the exact current test names - the count has drifted out of sync with the actual suites before, so it isn't pinned here).

Some api_handler_tests, server_e2e_tests, and version_firewall_tests cases require wire fixtures under tools/kafka-tool/kafka_messages/ (gitignored locally; CI generates them via scripts/ci-wire-fixtures.sh):

bash
./gateways/kafka/scripts/ci-wire-fixtures.sh generate
cargo test -p iggy-gateway-kafka
./gateways/kafka/scripts/ci-wire-fixtures.sh cleanup   # optional

Or generate only the keys the tests need:

bash
for key in 0 1 2 10 11 12 13 14 19 22; do
  cargo run -p kafka-message-gen -- generate \
    --output gateways/kafka/tools/kafka-tool/kafka_messages \
    --api-key "$key"
done

Manual testing

Before check-in, run the procedure in docs/MANUAL_TESTING.md (smoke, version firewall, kcat, adversarial cases).

Scoped APIs

See docs/SCOPE.md for #3421 deliverables, supported API key/version table, and post-foundation TODO backlog.

Design decisions

Delivery guarantees

Delivery through this gateway is at-least-once, and stays at-least-once across a gateway
restart. Transactions are not supported, and will not be. An idempotent Kafka producer is given
a producer id so that it starts, but its retries are not deduplicated: Produce uses the producer
id only to pick the producer's Iggy session, and ignores the epoch and sequence, so a retry after a
network timeout writes the record twice, and both copies reach the stream with their own offsets.

Iggy deduplicates writes on its own partition plane, and that does not close this gap, because it
guards the hop from the gateway to Iggy rather than the hop from the producer to the gateway.
docs/IDEMPOTENCE.md has the detail and what closing it needs.

Transactions are not supported, and will not be. AddPartitionsToTxn (24), AddOffsetsToTxn
(25), EndTxn (26) and TxnOffsetCommit (28) are never advertised, so a conforming client never
sends one; InitProducerId with a transactional_id answers UNSUPPORTED_VERSION (35); and a
Produce request carrying a transactional_id gets UNSUPPORTED_VERSION (35) on every partition
rather than having its records stored as if they were ordinary ones. None of those closes the
connection. docs/SCOPE.md's Transactions section has the ordering and the reasoning.

Consumer groups ([#3544](https://github.com/apache/iggy/issues/3544))

A stock consumer with a group.id joins, takes the partitions its own assignor hands it, commits,
and resumes from its commit. With the bridge on, commits live in Iggy, so a group also resumes after
a gateway restart. tests/kafka_client_e2e_tests.rs checks both with the Java consumer, and a
member that leaves hands its partition to the other at its commit.

Limits:

  • One gateway per bootstrap endpoint. Group membership is gateway memory
    (docs/CONSUMER_GROUPS.md).
  • Classic protocol only. A consumer with group.protocol=consumer (KIP-848) fails.
  • group.instance.id is accepted but not honoured. A static member that restarts causes two
    rebalances.
  • No DescribeGroups or ListGroups yet (#3548), so
    kafka-consumer-groups.sh cannot list or describe a group.
  • No DeleteGroups or OffsetDelete, and offsets never expire. An operator deletes a group's offsets
    in Iggy (docs/OFFSET_STORAGE.md).
  • Delivery is at-least-once. A consumer that stops before it commits reads those records again.

Listeners and TLS ([#4510](https://github.com/apache/iggy/issues/4510))

The gateway binds one listener per Kafka security protocol, named the way Kafka's own listeners
and advertised.listeners are. A client is pointed back, in Metadata and FindCoordinator, at the
listener it connected through and no other, so a client on the TLS port never learns the plaintext
one.

bash
IGGY_KAFKA_LISTENERS=PLAINTEXT://127.0.0.1:9093,SSL://0.0.0.0:9094,SASL_SSL://0.0.0.0:9095 \
  IGGY_KAFKA_ADVERTISED_LISTENERS=SSL://kafka.example.com:9094,SASL_SSL://kafka.example.com:9095 \
  IGGY_KAFKA_TLS_CERT_FILE=/etc/iggy/kafka.pem IGGY_KAFKA_TLS_KEY_FILE=/etc/iggy/kafka.key \
  cargo run -p iggy-gateway-kafka

Each security protocol can be bound once, since the protocol is also the listener's name.

TLS is rustls. One certificate serves every SSL and SASL_SSL listener, and the files are
re-read every IGGY_KAFKA_TLS_RELOAD_INTERVAL_SECS: a rotated certificate takes effect for new
connections without a restart, a key that does not match leaves the previous material serving and
logs an error. Rotate by writing the new files elsewhere and renaming them into place: a chain
written in place can be read half written, and if what was read still parses and matches the key,
the truncated chain serves until the next interval. There is no self-signed mode; mint a
certificate (docs/MANUAL_TESTING.md has an openssl recipe) and give the Kafka client the CA.

The certificate's subject alternative names must cover every host in
IGGY_KAFKA_ADVERTISED_LISTENERS: a client verifies the name of each broker address Metadata
hands it, so a host missing from the certificate fails the client's next connection, not the
first. Client certificates are checked against the CA only; there is no CRL or OCSP revocation
checking, so revoke access by deleting or deactivating the Iggy user a certificate maps to. That
applies to new connections only: an open connection keeps the principal its handshake resolved,
and the permissions that came with it, until it closes.

bash
kcat -b kafka.example.com:9094 -X security.protocol=SSL -X ssl.ca.location=ca.pem -L
kcat -b kafka.example.com:9095 -X security.protocol=SASL_SSL -X ssl.ca.location=ca.pem \
     -X sasl.mechanisms=PLAIN -X sasl.username=alice -X sasl.password=s3cret -L

Client certificates (mTLS)

IGGY_KAFKA_TLS_CLIENT_AUTH=required (or requested) makes every SSL listener verify client
certificates against IGGY_KAFKA_TLS_CLIENT_CA_FILE, and the certificate names the principal
there. Its subject, rendered the way Java does (CN=alice,OU=eng,O=Example), goes through
IGGY_KAFKA_TLS_PRINCIPAL_MAPPING_RULES, and the result must be the name of an active Iggy user.
The gateway looks the user up through the bridge's own session, so the bridge is required and its
Iggy user needs read_users. A certificate whose subject no rule maps, or that names no such user,
is closed after the handshake, with the subject or name in the gateway's log at warn.

A SASL_SSL listener never asks for a client certificate, which is Kafka's default for that
protocol: SASL names the principal there.

With requested, a client that presents no certificate is Kafka's ANONYMOUS principal, which
holds no permissions. Once any listener names principals (a SASL listener, or an SSL listener
with client authentication), every connection that proved no identity is ANONYMOUS, on a
PLAINTEXT listener beside it too. The requests the gateway checks against a principal,
DeleteTopics, AlterConfigs and the auto-creation a Metadata request asks for, need
manage_topics and are refused for it; everything else is served as the bridge's user. A gateway whose listeners name nobody serves every request as the
bridge's user.

The rules are Kafka's syntax, tried in order: RULE:pattern/replacement/[LU] applies when the
pattern matches the whole subject, DEFAULT keeps the subject as is. Iggy accepts any username of
3 to 50 characters, but a raw subject is usually longer than that, and is rarely the name the
operator created, so a deployment that maps identities sets at least one rule. Patterns are a
Java-compatible subset: \d \s \w match ASCII only and . stops at Java's line terminators, as on
a broker, while (?i), the u, U, m, x and R flags, \p{..}, POSIX classes, \b, \v,
possessive quantifiers and the -- and ~~ class operations are refused at startup
(docs/AUTHENTICATION.md says why). A replacement that
names a group the pattern does not have is refused at startup. That is stricter than Kafka, which
writes such a $N literally: RULE:^CN=(.*)$/$2/ maps to $2 on a broker. The reverse case,
/$1\$2/, makes Kafka throw while the gateway maps alice to alice$2, which is harmless because
that Iggy user must still exist:

bash
IGGY_KAFKA_TLS_PRINCIPAL_MAPPING_RULES='RULE:^CN=([^,]+),OU=services.*$/svc-$1/L,RULE:^CN=([^,]+).*$/$1/'

Acting as the mapped user on the data plane is
#4518; today the identity gates admission and
answers DescribeAcls, as a SASL identity does.

Authentication ([#3549](https://github.com/apache/iggy/issues/3549))

Off by default. With a SASL_PLAINTEXT or SASL_SSL listener (or IGGY_KAFKA_SASL_ENABLED=true
in the single-listener form) the gateway requires SASL/PLAIN before it serves any other API on
that listener, and it verifies the credentials by logging into Iggy with them.

The username and password a Kafka client sends are an Iggy username and password. There is no
mapping table and no credential store in the gateway: create an Iggy user for each Kafka principal
and point the client at it. Credentials are verified against IGGY_KAFKA_IGGY_ADDR.

bash
IGGY_KAFKA_SASL_ENABLED=true IGGY_KAFKA_IGGY_ADDR=127.0.0.1:8090 cargo run -p iggy-gateway-kafka

Transport security to Iggy is configured separately from the Kafka side, because the two protect
different hops. These variables cover every connection the gateway makes to Iggy, the credential
check's and the bridge's alike, and are refused while neither SASL nor the bridge is on:

Variable Default Description
IGGY_KAFKA_IGGY_TLS_ENABLED false Encrypt the gateway's connections to Iggy (true or false, nothing else). Required if the Iggy server only accepts TLS, otherwise the bridge fails to connect and every verification fails as unreachable
IGGY_KAFKA_IGGY_TLS_DOMAIN derived from the address Name checked against the Iggy server certificate
IGGY_KAFKA_IGGY_TLS_CA_FILE SDK bundled roots PEM roots to trust. Note the SDK does not use the system trust store

Client side, for example with kcat:

bash
kcat -b 127.0.0.1:9093 -X security.protocol=SASL_PLAINTEXT -X sasl.mechanisms=PLAIN \
     -X sasl.username=alice -X sasl.password=s3cret -L

Four things to know before switching it on:

  • PLAIN sends the password in the clear unless the hop is encrypted: a SASL_SSL listener on
    the Kafka side, IGGY_KAFKA_IGGY_TLS_ENABLED=true on the Iggy side. A SASL_PLAINTEXT listener
    is only safe on a trusted network, and the gateway says so at startup. SCRAM cannot be offered at
    all, because Iggy stores one Argon2 hash per user and SCRAM needs PBKDF2-derived keys that cannot
    come from it.
  • Enabling it breaks every existing client at once. The two SASL keys appear in the
    ApiVersions advertisement only while it is on, and unauthenticated clients are refused.
  • Every connection costs a login, meaning one password hash on an Iggy shard thread and one
    replicated registration. Verification is deliberately not cached, since caching it per username
    would let a second connection present any password. Connection churn is therefore server load.
    IGGY_KAFKA_MAX_CONCURRENT_AUTHENTICATIONS bounds the checks the gateway runs at once, and a peer
    whose login was rejected is refused for a delay that doubles per rejection, from 0.5s up to 30s.
  • Authentication and an ACL view, not enforcement. The gateway verifies the credentials and
    can describe what Iggy grants the principal, but no operation checks those grants. Produce and
    Fetch act as the bridge's Iggy user for every client, so any client that signs in reads and
    writes what that user can.

ACLs

DescribeAcls renders the authenticated principal's Iggy permissions as Kafka ACL bindings, so
kafka-acls.sh --list works against the gateway. It is read only: CreateAcls and DeleteAcls
are not implemented and not advertised.

--list here is a snapshot of the caller's own grants, not the broker-wide dump it is against a
Kafka cluster. A principal sees its own permissions and nobody else's, because the gateway holds no
administrative credentials, so filtering on another User: returns an empty listing whatever that
user holds, and root's listing is root's grants rather than a catalog of every binding. Only global permissions are rendered, as wildcard bindings, and the view is a snapshot
taken when the connection authenticated, so a permission changed afterwards is invisible until the
client reconnects. docs/ACL_MAPPING.md has the mapping table and what is
deliberately left out.

Full reasoning, including what was rejected and why, is in
docs/AUTHENTICATION.md.

Iggy bridge ([#3533](https://github.com/apache/iggy/issues/3533))

src/bridge/ is the SDK integration layer: connects to Iggy, maps Kafka topics to Iggy
streams/topics, provisions them on demand, looks up high watermarks (one or many partitions of a
topic per call) and records by time for ListOffsets, probes and polls partitions for Fetch, and
stores and reads group offsets for OffsetCommit and OffsetFetch.
Produce (#3535), Fetch (#3536), ListOffsets
(#3537), Metadata (#3534), CreateTopics
(#3538), DescribeConfigs, AlterConfigs and IncrementalAlterConfigs, OffsetCommit and OffsetFetch
(#3542) call it.
Topic configuration behavior is in docs/CONFIGS.md.
Tested by bridge's own unit tests, tests/bridge_iggy_integration_tests.rs and the
tests/*_real_bridge_tests.rs suites. All but the unit tests start a real iggy-server.

Produce ([#3535](https://github.com/apache/iggy/issues/3535))

One partition, one Iggy send. Each partition answers for itself.

Field Gateway
Partition Index as sent. Both count from 0.
Base offset From the send confirmation. -1 if none.
log_start_offset Always -1.
acks 0, 1, -1 write the same. Other values: 21, before any per-partition check.
acks=0 Writes, answers nothing. Any failed partition closes the connection.
Topics Creates none. Missing: 3. Bad name: 17.
timeout_ms Honored, max 20 s. Past it: 7.
Compression gzip, snappy, lz4. zstd from v7, else 76.
Producer id Picks the producer's Iggy session. See Concurrency ceiling.
Producer epoch, sequence Ignored, so a retry writes twice.
Timestamps over about 71 min apart Clamped into one send. kafka.ts keeps the real one.
Several batches in one partition 87, as Kafka.
Bytes after the batch 87.
More records than the batch declares 87, as Kafka.
Same partition twice in one request Written twice. Kafka keeps the last.
Repeated header name in a record 87.
Produce v0-2, acks=0 Closes the connection.
Undecodable request Closes the connection.
Code When Client
10 Record, send or partition too large, even alone Java splits multi-record batches. Else fails.
87 Record the gateway cannot map. Reason in error_message from v8. Fails.
35 Transactional or control batch Fails.
6 Request budget ran out. Nothing written. Retries.
7 Deadline passed: in the send, waiting for decode memory, or behind the producer's own earlier send. Or connection lost mid-send. May be written. Retries. Can duplicate.

Partition cap: max_frame_size decompressed bytes, max_frame_size / 64 record slots, 3 headers
per slot. Past it: 10.

Request budget: 8 partition caps, refused partitions included. Past it: 6 for the rest, not
decoded.

Decode memory: 5 partition caps across all requests, counted in bytes. A request holds what its
largest partition may decode: its bytes or 64 per record slot, whichever is more, or a whole cap
when compressed. One holding 64 KiB or more first takes one of 4 large slots, and holds no memory
while it waits for one. So large requests hold 4 caps at most, and leave 1 to the small ones.

A request waits for its producer's turn first, and holds no slot and no memory while it does.
Past its deadline waiting for any of the three: 7. A send that outlives its request keeps its slot
and its memory until it ends, 45 s at most.

docs/BRIDGE_MAPPING.md describes what a record becomes once it is stored.

Fetch ([#3536](https://github.com/apache/iggy/issues/3536))

Per request: one probe per topic, shared with other Fetches, then polls of each partition with
records, in request order. One uncompressed batch per partition.

Field Gateway
Partition, offset As sent. Both systems count from 0.
Iggy replica The one on the node each bridge client is on. Every client signs in at the metadata leader, and a view change or a refused call can move it. So in a cluster, a probe and a poll can read nodes that are not at the same offset.
Offset below the oldest kept one Reads from the oldest kept one. If none is kept, no records until the next write, because a cluster replica that lost records after a crash reads the same. Kafka answers 1 here, so auto.offset.reset=none gets no error.
Recreated topic Iggy starts each partition at offset 0 again, and Fetch cannot see it. Seek consumers to 0 after you recreate a topic. Otherwise writes past a consumer's old offset make it skip the records below.
high_watermark One past the last committed offset.
last_stable_offset Same as high_watermark. No transactions.
log_start_offset Always -1.
Sessions None opened. session_id is always 0, so every request is a full one.
max_wait_ms Honored, max 18 s.
min_bytes Honored, as Kafka does: the request waits until the records read reach it. New bytes are estimated from Iggy's average message size.
max_bytes Honored, max IGGY_KAFKA_MAX_FRAME_SIZE.
partition_max_bytes Honored.
First partition with records At least one record, even past every byte limit (KIP-74).
Records per partition At most 1000 per request, polled up to 16 at a time.
isolation_level, replica id, epochs, rack, forgotten topics Ignored.
Code When Java consumer
OFFSET_OUT_OF_RANGE (1) Offset below 0. Or an offset still past high_watermark after 30 s of 6 on the same connection, counted from its first 6 there. A read there in range starts the 30 s over. A node that lags clears within them. A partition with no offset past 0 never answers 1, because Iggy reports that while it loads, fences or rebuilds one. Resets its offset (auto.offset.reset).
UNKNOWN_TOPIC_OR_PARTITION (3) Topic or partition not in Iggy, or a name Kafka refuses Refreshes metadata, retries.
NOT_LEADER_OR_FOLLOWER (6) Iggy unreachable, too slow, not signed in, loading the partition, returning no records where it reports some, or an offset past the end (see 1). Alone, it goes out after max_wait_ms. Refreshes metadata, retries.
TOPIC_AUTHORIZATION_FAILED (29) The bridge's Iggy user lacks permission. Fetch reads as that user, not as the client. Fails.
UNKNOWN_SERVER_ERROR (-1) Anything else, such as a stored message the gateway cannot map. Alone, it goes out after max_wait_ms. Each connection reads the partition again at most once per max_wait_ms. Retries the same offset.
FETCH_SESSION_ID_NOT_FOUND (70) Top level. The request continues a session. Sends a full request.

The Java consumer throws on any other partition code, so Fetch sends none.

Limits:

  • 20 s per request, wait included, so a waiting Fetch fits the 25 s shutdown drain. Partitions
    not read by then answer 0 with no records.
  • 4 requests read and encode at once. A waiting request, the probes that decide empty polls, or a
    response on a slow socket hold no slot. So response memory grows with connections: max_bytes
    each, or one larger record.
  • A message the gateway cannot map stops the consumer at its offset. Records before it are served.
    The log shows it once per connection at warn.
  • Each slot polls on its own Iggy client, which connects at the slot's first poll. A poll ends at
    the request deadline, so a partition that Iggy refuses holds up only its own slot. The SDK can
    hold that connection for up to 30 s more, so the slot drops the client and connects a new one.
  • Iggy polls by count, not bytes. One poll can load 16 of the largest messages: about 1 GB, and
    about 4 GB over the 4 slots. It also costs polls: 1000 small records take 63. When Iggy polls
    take a byte cap, both limits go away.
  • Probes run on one more Iggy client of their own, so they never wait behind a Produce. A request
    waits at most 2 s for its probes, then reads with what it has.
  • A request loads at most 100 new topic probes per round, the oldest first. Other topics use an
    older probe. A waiting request probes 100 topics per round, in turn.
  • An empty poll waits for a probe of its topic that starts after it. A read takes one such probe
    per topic, after its last empty poll there, for at most 100 topics. The other empty polls answer
    no records.
  • A partition with no room left in max_bytes gets no poll, unless the request still owes its
    first record.
  • A probe that fails with 6 or -1 leaves the last good probe in use for up to 30 s, and the polls
    decide. That probe never proves a consumer caught up, so an offset at its end gets a poll too.
  • A partition that Iggy reports as loading answers 6 for 30 s, counted for the whole gateway.
    After that, Fetch polls it and the log warns once. Counters that drift read the same way.
    ListOffsets answers 6 for it all along, and warns too.
  • A Produce through the gateway wakes a waiting Fetch of that topic at once. That includes a
    Produce that ends during the Fetch's first read. A native Iggy write wakes it at the next probe,
    within about 200 ms.

Connection config

Variable Default Description
IGGY_KAFKA_IGGY_ADDR 127.0.0.1:8090 Address of the Iggy server to bridge to
IGGY_KAFKA_IGGY_USERNAME iggy Iggy username
IGGY_KAFKA_IGGY_PASSWORD none - required Iggy password. No default: iggy-server only uses the well-known iggy/iggy root credentials when started with --with-default-root-credentials (dev-only). Otherwise the operator sets the root password, so a hardcoded default here could never be right and would invite running as root unnoticed
IGGY_KAFKA_IGGY_STREAM kafka Default Iggy stream for a Kafka topic with no explicit mapping override
IGGY_KAFKA_TOPIC_MAP_PATH unset Path to a topic-mapping TOML file (see below); omit to use only the default rule
IGGY_KAFKA_IGGY_MAX_MESSAGE_SIZE 64MiB Iggy's message_bus.max_message_size. Set both together. Larger partitions answer 10
IGGY_KAFKA_PRODUCE_SESSIONS 256 Producers that get an Iggy client of their own. The rest share one. 0: all share one
IGGY_KAFKA_PRODUCE_SESSION_IDLE_SECS 600 An unused Produce client closes after this long

The initial connect retries a fixed, bounded number of times (RECONNECTION_RETRIES = 3, not the
Iggy SDK client's own default of unlimited retries, one dial per second, forever), and the whole
attempt - retries included - is capped at REQUEST_TIMEOUT (15s) wall-clock, so IggyBridge::connect
fails in bounded time whether the address refuses the connection or silently drops it, instead of
blocking the calling task indefinitely. Every other bridge call (ensure_stream_and_topic,
high_watermark(s), probe, close) carries the same REQUEST_TIMEOUT for the same
reason: the SDK reconnects internally, mid-call, on a transport error, through the same
undead-lined dial path - a bridge call made well after the initial connect can still hit this if
Iggy becomes unreachable later.

send_records uses the Produce deadline instead:

  • One request of a producer runs at a time, on that producer's client.
  • A send past its deadline keeps the producer's turn until it ends. That producer's requests answer
    7 meanwhile. Other producers send on.
  • After 45 s the gateway gives up on the send. It then closes that connection before the turn
    frees, so the send cannot land after a later one. The SDK ends its write within 30 s more,
    unless Iggy stopped reading: the producer then waits until that connection breaks.
  • No other API waits behind a stuck send. Each uses another client.

Topic mapping

Default rule, no config file needed: a Kafka topic orders maps to Iggy stream
IGGY_KAFKA_IGGY_STREAM (default kafka), topic orders - the Kafka topic name carries over
unchanged. Override specific topics with a TOML file:

toml
default_stream = "kafka"

[topics.orders]
stream = "billing"
topic = "orders_v2"

# A Kafka topic name containing dots needs the key quoted, or TOML parses it as nested
# tables ([topics.org] containing [apache] containing [kafka]) instead of one topic named
# "org.apache.kafka.events".
[topics."org.apache.kafka.events"]
stream = "billing"
topic = "kafka_events"

Point IGGY_KAFKA_TOPIC_MAP_PATH at the file to load it; topics not listed under [topics.*]
still fall back to the default rule.

default_stream is required in a map file - it has no #[serde(default)], unlike topics -
so an override-only file with no default_stream key fails to load rather than falling back to
kafka. When both IGGY_KAFKA_TOPIC_MAP_PATH and IGGY_KAFKA_IGGY_STREAM are set, the file's own
default_stream always wins and the env var is ignored entirely: a TOML file is a complete mapping
document, not an overlay on top of the env var.

Provisioning and idempotency

ensure_stream_and_topic(kafka_topic, partition_count) creates the mapped Iggy stream and topic
if either is missing. Idempotent when repeated with the same partition_count: a no-op if both
already exist with that count, and a NameAlreadyExists race against a concurrent caller creating
the same stream/topic is treated as success, not an error - the goal is "it exists," not "this
call created it." A different partition_count against an already-existing topic returns
BridgeError::PartitionCountMismatch rather than silently keeping the old count or growing it -
two concurrent callers requesting different counts for the same topic must not both see success.

Topics created this way have no message expiry - Iggy's own server default, not Kafka's 7-day
default. AlterConfigs or IncrementalAlterConfigs can set that expiry afterwards through retention.ms
(docs/CONFIGS.md). Until a client does, messages never expire, so a Kafka app
that assumes the 7-day default will accumulate data on this bridge.

They also use Iggy's default durability, Durability::Replicated - quorum commit without an
additional stable-storage barrier, with the disk write itself threshold-gated (flushed at 1024
messages or 1 MiB of unflushed data, whichever comes first). Kafka's own defaults take the same
posture, so this isn't a wrong choice, but on a single node both can lose an acked write to a power
cut before that threshold is reached - worth knowing rather than discovering later.

The bridge also keeps the Kafka cluster id in Iggy, in topic cluster_id of stream
<default stream>.meta, created on the first start with durability persisted, no expiry and no
size cap. Leave that stream alone: deleting it gives the next start a new id, and a topic mapping
override that targets it is refused at startup. On the start that stores the id, the bridge's Iggy
user needs to create whichever of that stream and topic is missing, then send_messages and
poll_messages on the stream. Every later start only polls. A user without manage_streams can
still run the gateway once an operator creates the stream and the topic (one partition,
durability persisted) and grants it poll_messages and send_messages on the stream: the
bridge creates only what it finds missing. See docs/SCOPE.md.

Auto topic creation

With IGGY_KAFKA_AUTO_CREATE_TOPICS_ENABLED=true, the gateway behaves like a Kafka broker with
auto.create.topics.enable=true. Producers, Connect source tasks, Debezium and dead-letter
topics that rely on creation by first use then work without a CreateTopics call.

  • A Metadata request that names a missing topic and allows auto-creation (always below v4, the
    request's allow_auto_topic_creation from v4) starts creating it with
    IGGY_KAFKA_AUTO_CREATE_TOPICS_PARTITIONS partitions and answers UNKNOWN_TOPIC_OR_PARTITION
    (3), as Kafka does. The client finds the topic on its next refresh.
  • A Metadata request for all topics never creates anything, and neither does Produce or Fetch.
  • An invalid topic name is answered INVALID_TOPIC_EXCEPTION (17). Kafka's internal topics,
    __consumer_offsets, __transaction_state and __share_group_state, are answered
    UNKNOWN_TOPIC_OR_PARTITION and never created. Any other name starting with __, such as a
    Debezium heartbeat topic, is created like any other.
  • When the connection has a principal (a SASL login, a client certificate mapped to an Iggy user,
    or ANONYMOUS beside either), it needs a global manage_topics or manage_streams. Only global
    grants count: manage_topics granted on one stream does not, even where Iggy would let the
    principal create the topic. A principal without it is answered TOPIC_AUTHORIZATION_FAILED (29)
    for each missing topic it names, and nothing is created; topics that exist are answered as usual.
    Kafka clients treat 29 as fatal, so a consumer whose principal may not create topics should set
    allow.auto.create.topics=false, which keeps its lookups of a missing topic answered
    UNKNOWN_TOPIC_OR_PARTITION (3). A principal whose permissions could not be read is answered 3
    and nothing is created, since 29 is fatal to Java producers, consumers and Streams while Produce
    and Fetch still work for it. An explicit CreateTopics is not checked against manage_topics
    today, so a principal refused auto-creation can still create the same topic with CreateTopics.
  • A topic already being created is not started twice. At most 100 creations run at once; a name
    past that is started by a later refresh. A creation Iggy refuses, for example at
    metadata.partitions_max, is not retried for 1 second. One Iggy refuses for the bridge user's
    permissions is not retried for a minute: Iggy refuses those only after journaling the create, so
    each attempt costs a replicated metadata write. A name waiting out either still counts toward the
    1. Its first failure is logged at warn, and repeats within a minute at debug.
  • Creations run as the bridge's Iggy user, like CreateTopics, on an Iggy client of their own.
    Both create the topic first, and the backing Iggy stream, which the topic mapping names and the
    client never does, only when Iggy reports it missing. The bridge user needs manage_topics on
    that stream, or globally, to create a topic in a stream that exists, and the global
    manage_streams to create a missing stream. A principal with manage_topics but no stream
    rights still gets a new stream when the mapping names one that does not exist yet.
  • Like every topic the gateway creates, an auto-created topic has no message expiry (see
    Provisioning and idempotency): its messages are kept until a
    client sets retention.ms with AlterConfigs.
  • Shutdown cancels the creations still running before the bridge signs out, and drops their
    client if any was running, so the sign-out does not wait behind it. An embedder that builds its
    own GatewayState calls shutdown_auto_topic_creation before closing the bridge.
  • IGGY_KAFKA_AUTO_CREATE_TOPICS_PARTITIONS applies to auto-creation only. A CreateTopics with
    num_partitions=-1 still creates 1 partition.

As in Kafka, a producer or consumer still running recreates a topic right after DeleteTopics
removes it, on its next Metadata refresh. A Java consumer with allow.auto.create.topics=true
creates whatever topic it subscribes to, including one whose name is a typo.

With auto-creation off, the default, nothing changes: a missing topic is answered
UNKNOWN_TOPIC_OR_PARTITION and a client waits until something else creates it.

Concurrency ceiling

  • Each producer sends on an IggyClient of its own, one request at a time, so its batches land
    in order. Different producers send at once.
  • A producer is its producer id, if this gateway gave that id out since it started, plus its user
    when SASL is on. Every other producer is its Kafka connection: no id, an id from before a
    gateway restart, or an id from another gateway.
  • Up to IGGY_KAFKA_PRODUCE_SESSIONS (256) of these clients are open. A full pool closes its
    least recently used free client for a new producer, if that client sat unused for 30 s.
    Otherwise the producer shares 1 more client, one request at a time, and moves to its own at a
    later request, once there is room and none of its requests runs.
  • IGGY_KAFKA_MAX_CONNECTIONS (1024) allows more producers than sessions, so raise the session
    limit to give each its own.
  • Each client costs Iggy a connection, a slot of metadata.clients_table_max (8192), and a slot
    of partition.dedup_clients_max (4096) in each partition it writes.
  • A client whose producer is a connection closes, signed out, when that connection ends. Any
    other closes after IGGY_KAFKA_PRODUCE_SESSION_IDLE_SECS (600) unused, checked every 30 s.
  • 4 clients sign in at once, 80 a second at most. More wait, up to their request's deadline.
  • IGGY_KAFKA_PRODUCE_SESSIONS=0: all producers share one client, one request at a time.
  • Metadata, CreateTopics, DeleteTopics, CreatePartitions, DescribeConfigs, AlterConfigs and
    IncrementalAlterConfigs share 1 more client, one Iggy request at a time. Fetch polls use 4
    more, one per read slot, and a ListOffsets lookup by time borrows one of those slots for its
    request. Topic probes use 1
    more. With auto topic creation on, creations use 1 more, opened at the first one.
    Offset calls use 8 more: a commit client and a read client per offset slot.
  • CreateTopics, DeleteTopics and CreatePartitions writes also take one gateway-wide lock, so a
    CreatePartitions can read a topic's count and add to it with nothing in between. A
    CreatePartitions whose write outcome is unknown keeps the lock while it re-reads the topic, for
    at most 50 s after its request arrived: the 30 s request ceiling plus 20 s from the write. Admin
    calls behind it wait up to their own deadline, then answer retriable REQUEST_TIMED_OUT (7).
    The Java admin client does not retry that code by itself: kafka-topics.sh and
    Admin.createPartitions report a TimeoutException, and the caller must retry.
  • OffsetCommit and OffsetFetch cost one Iggy call per partition, spread over the offset slots. In a
    cluster, an offset commit client can also open one connection per node for stores.
  • Order: set max.in.flight.requests.per.connection=1, or retries=0. Otherwise a retried batch
    can land after later ones.
  • Order after a 7: the send can still run. A producer with a producer id waits behind it on any
    connection. A producer that is its connection does not once it reconnects, so its retry or its
    next batch can land first. To keep that order use an idempotent producer, the Java default, or
    IGGY_KAFKA_PRODUCE_SESSIONS=0.

Fetches share topic probes: one get_topic per topic at a time, for all consumers together, on
the probe client. A caught-up consumer of a busy topic can start one per Fetch. A waiting Fetch
costs about 10 get_topic calls per second per topic. ListOffsets probes on that client too.

Error mapping

BridgeError::to_kafka_error_code() maps Iggy failures to Kafka wire error codes:

  • Stream, topic or partition not found → UNKNOWN_TOPIC_OR_PARTITION (3). This includes the
    generic ResourceNotFound that a partition request returns when the server cannot resolve it
  • A rejected permission (Unauthorized) → TOPIC_AUTHORIZATION_FAILED (29) - a real,
    fixable-by-the-Kafka-operator ACL problem
  • A rejected login (the bridge's own IGGY_KAFKA_IGGY_USERNAME/_PASSWORD are wrong) →
    UNKNOWN_SERVER_ERROR (-1), deliberately not 29 - the Kafka client can't fix a bridge-side
    credential misconfiguration, and blaming its own ACLs for one is worse than an unexplained
    fatal error
  • Connection-shaped failures → NOT_LEADER_OR_FOLLOWER (6). On a send, Disconnected,
    EmptyResponse, TcpError and StaleClient → 7 instead: the write may have landed
  • An Iggy commit whose outcome is genuinely unknown (TransientNotCommitted) →
    REQUEST_TIMED_OUT (7) - retriable in real Kafka too, chosen because it's what a real broker
    sends for the same shape of failure, not to make a client stop retrying
  • A bridge-side call timeout (BridgeError::Timeout) → REQUEST_TIMED_OUT (7), the same code and
    the same reasoning as TransientNotCommitted above - the SDK's write/read run on a task this
    timeout cannot abort, so the outcome is unknown, not known-safe-to-retry (see Connection config's
    timeout caveat above)
  • An invalid Kafka-side topic name (empty, whitespace-padded, oversized, illegal characters) →
    INVALID_TOPIC_EXCEPTION (17), checked before any Iggy call is made
  • PartitionCountMismatch → TOPIC_ALREADY_EXISTS (36, not INVALID_PARTITIONS - that code's
    own text is "below 1", a different condition)
  • Too many partitions requested (TooManyPartitions) → INVALID_PARTITIONS (37), reachable
    through ensure_topic's partition_count argument once it exceeds the server's cap
  • A create past the node's partition cap (PartitionsLimitReached, 2022) → POLICY_VIOLATION
    (44). See metadata.partitions_max below
  • Anything else → UNKNOWN_SERVER_ERROR (-1)

Fetch folds these into the codes a consumer handles: 7 → 6, 17 → 3, and any other code → -1.
OffsetCommit and OffsetFetch fold 6, 7 and RequestTooOld into COORDINATOR_LOAD_IN_PROGRESS
(14), which the Java client retries in both (to_offset_error_code). ListOffsets answers
HistoryUnavailable, LifecycleBusy and ClientShutdown with 6 and RequestTooOld with 7
instead of -1, because the Java AdminClient fails a partition on any non-retriable code
(to_list_offsets_error_code). HistoryUnavailable refuses a read routed to an older
incarnation of the partition, as after a delete and recreate; the retry probes again and answers 3
if the topic is gone. ListOffsets reads do not get RequestTooOld today, so that arm is defensive.

Server limits the gateway inherits

These are Iggy server limits, not gateway settings. A Kafka client cannot act on any of them, so
an operator has to.

Limit Default Where
Consumer offset keys per partition, per consumer kind 4096, ceiling 262144 partition.consumer_offsets_max
Partitions of one node, all streams and topics 0, no cap metadata.partitions_max
One user header name, and one header value 255 bytes fixed, user_headers.rs
All user headers of one message 100 KB fixed, MAX_USER_HEADERS_SIZE
Message payload 64 MB fixed, MAX_PAYLOAD_SIZE

Only the first two are configurable. A Kafka consumer group commits one offset key per partition
it holds, so partition.consumer_offsets_max is what bounds the number of groups that can commit
against one partition. Passing it returns TooManyConsumerOffsets (3024), which reaches the
client as UNKNOWN_SERVER_ERROR because Kafka has no code for the condition. The gateway logs
the real Iggy error, so the server log is where an operator diagnoses it.

A CreateTopics or CreatePartitions that would take the node past metadata.partitions_max
returns PartitionsLimitReached (2022), which reaches the client as POLICY_VIOLATION (44). The
cap counts committed partitions only, so creates that run at the same time can go past it. A
CreatePartitions on a topic created with a fixed partition count (PartitionResizeDisabled,
2023) reaches the client as POLICY_VIOLATION too, validate_only included. Topics the gateway
creates are resizable.

The other three decide when a Kafka record goes into the envelope instead of being stored
natively. See docs/OFFSET_STORAGE.md and
docs/BRIDGE_MAPPING.md.

Wire fixture tool

See tools/kafka-tool/README.md.


Overview (`core/ai/mcp/README.md`)

Apache Iggy MCP Server

The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to LLMs. The Apache Iggy MCP Server is an implementation of the MCP protocol for the message streaming infrastructure.

Start an Iggy broker from the same checkout first. For a development broker started with --with-default-root-credentials, run from the repository root:

sh
IGGY_MCP_IGGY_USERNAME=iggy IGGY_MCP_IGGY_PASSWORD=iggy cargo run --bin iggy-mcp

Use the credentials or PAT of your existing broker when connecting to another installation.

The docker image is available, and can be fetched via docker pull apache/iggy-mcp:edge.

The minimal viable configuration requires at least the Iggy credentials, to create the connection with the running Iggy server using TCP with which the MCP server will communicate. You can choose between HTTP (the default) and STDIO transports (e.g. for the local usage with tools such as Claude Desktop choose stdio).

toml
transport = "stdio" # http or stdio are supported

[iggy]
address = "localhost:8090" # TCP address of the Iggy server
username = "iggy"
password = "iggy"
# token = "secret" # Personal Access Token (PAT) can be used instead of username and password
# consumer = "iggy-mcp" # Optional consumer name

[iggy.tls] # Optional TLS configuration for Iggy TCP connection
enabled = false
ca_file = "core/certs/iggy_cert.pem"
domain = "" # Optional domain for TLS connection

[http] # Optional HTTP API configuration
address = "127.0.0.1:8082"
path = "/mcp"

[http.cors] # Optional CORS configuration for HTTP API
enabled = false
allowed_methods = ["GET", "POST", "PUT", "DELETE"]
allowed_origins = ["*"]
allowed_headers = ["content-type"]
exposed_headers = [""]
allow_credentials = false
allow_private_network = false

[http.tls] # Optional TLS configuration for HTTP API
enabled = false
cert_file = "core/certs/iggy_cert.pem"
key_file = "core/certs/iggy_key.pem"

[permissions]
create = true
read = true
update = true
delete = true

The configuration file must use TOML. The default path is core/ai/mcp/config.toml, relative to the working directory; override it with IGGY_MCP_CONFIG_PATH. Embedded defaults are loaded first, then the file if present, then environment overrides such as IGGY_MCP_IGGY_USERNAME and IGGY_MCP_HTTP_ADDRESS. Nested settings also use underscores, for example IGGY_MCP_IGGY_TLS_ENABLED.

Set IGGY_MCP_ENV_PATH to load a particular dotenv file. Otherwise .env is searched for in the current directory and its parents. Existing environment variables take precedence over dotenv values.

Run iggy-mcp --list-config-env-vars to print the supported configuration environment variables and exit before loading dotenv or configuration files or creating the runtime.

A non-empty iggy.token takes precedence over username and password. It accepts a literal PAT or a file: reference such as file:/run/secrets/iggy_pat; file contents are trimmed and a leading ~/ expands to the home directory.

iggy-mcp rejects unknown command-line arguments with exit code 2. Keep the client or container args list empty unless it contains a supported flag.

Set command to the absolute path of the built executable. This Claude Desktop example uses the development broker credentials:

json
{
  "mcpServers": {
    "iggy": {
      "command": "/path/to/iggy-mcp",
      "args": [],
      "env": {
        "IGGY_MCP_TRANSPORT": "stdio",
        "IGGY_MCP_IGGY_ADDRESS": "localhost:8090",
        "IGGY_MCP_IGGY_USERNAME": "iggy",
        "IGGY_MCP_IGGY_PASSWORD": "iggy"
      }
    }
  }
}

Remember to use the appropriate Iggy account credentials for your environment (e.g. create the user with read-only permissions to avoid modifying the data). On top of this, you can also configure the permissions for the MCP server to control which operations are allowed (this will be checked first, before forwarding the actual request to the Iggy server).

Tool permissions

Each tool checks these MCP permissions before forwarding the request. The broker also enforces the authenticated Iggy account's permissions.

Permission Tools
read ping, get_cluster_metadata, get_stream, get_streams, get_topic, get_topics, poll_messages, get_stats, get_me, get_client, get_clients, snapshot, get_consumer_group, get_consumer_groups, get_consumer_offset, get_personal_access_tokens, get_user, get_users
create create_stream, create_topic, create_partitions, send_messages, create_consumer_group, create_personal_access_token, create_user
update update_stream, update_topic, store_consumer_offset, update_user, update_permissions, change_password
delete delete_stream, delete_topic, delete_partitions, delete_segments, delete_consumer_group, delete_consumer_offset, delete_personal_access_token, delete_user

poll_messages additionally requires update when auto_commit = true or strategy = "next". The next strategy enables auto-commit even when auto_commit is omitted or false. For read-only polling, use offset, first, last, or timestamp with auto-commit disabled.

Recovering uncertain writes

Write errors can include data.operation_id as a decimal string. Keep this value
exactly as returned. resolve_operation retries the retained original operation
with its original identity; success proves it committed and releases its lane.
It does not reconstruct the original response body. If the result is still
uncertain, the operation remains available for another resolve.

abandon_operation explicitly releases local recovery state. It does not undo
the write or establish whether it committed. Retrying the business action after
abandoning it can duplicate an earlier effect. Both tools take
{"operation_id": "123"} and require at least one of the create, update, or
delete MCP permissions. A process restart loses this local recovery state.

Systemd integration

Build with the systemd feature to enable systemd readiness and watchdog notifications:

sh
cargo build --bin iggy-mcp --release --features iggy-mcp/systemd

Readiness is sent after the HTTP listener starts or the stdio session initializes. The watchdog sends keep-alive notifications at half the interval supplied by systemd. SIGINT, SIGTERM, and stdio client disconnect trigger shutdown and a stopping notification.

Telemetry

The MCP server supports OpenTelemetry for logs and traces. To enable telemetry, add the following configuration:

toml
[telemetry]
enabled = true
service_name = "iggy-mcp"

[telemetry.logs]
transport = "grpc" # Options: "grpc", "http"
endpoint = "http://localhost:4317"

[telemetry.traces]
transport = "grpc" # Options: "grpc", "http"
endpoint = "http://localhost:4317"

For HTTP export, set transport = "http" and use complete signal URLs: http://localhost:4318/v1/logs for logs and http://localhost:4318/v1/traces for traces. The MCP server does not append those paths.

Shutdown flushes pending logs and spans before stopping the async runtime.


Overview (`core/bench/dashboard/README.md`)

Apache Iggy Benchmarks Dashboard

A modern, high-performance benchmark results dashboard for Iggy, built with Rust. This application provides a responsive web interface for visualizing and analyzing benchmark results.

Features

  • 📊 Interactive performance trend visualization
    • Support for multiple benchmark types:
      • Send (Producer) benchmarks
      • Poll (Consumer) benchmarks
      • Send & Poll (Combined) benchmarks
      • Consumer Group Poll benchmarks
    • Separate visualization for producer and consumer metrics
    • Comprehensive latency metrics (Average, P95, P99, P999)
    • Trend and throughput charts across all benchmarks, hardware and versions
  • 🔍 Filter benchmarks by hardware and version
  • 📱 Responsive design that works on desktop and mobile
  • 🚀 High-performance Rust backend
  • ⚡ Fast, modern web frontend built with Yew

Project Structure

The project is organized as a Rust workspace with four main components:

  • frontend/: Yew-based web application

    • Modern UI with interactive charts
    • Type-safe API integration
    • Error handling and loading states
  • server/: Actix-web REST API server

    • Efficient file system operations
    • Configurable through command-line arguments
    • Built-in security features
  • shared/: Common code between components

    • Type definitions
    • Serialization logic
    • Shared constants

API Endpoints

The server provides the following REST API endpoints:

Health Check

  • GET /health
    • Check server health status
    • Response: {"status": "healthy"}

Hardware Information

  • GET /api/hardware
    • List all available hardware configurations
    • Response: Array of hardware configurations

Git References

  • GET /api/gitrefs/{hardware}
    • List git references for specific hardware
    • Parameters:
      • hardware: Hardware configuration identifier
    • Response: Array of git reference strings

Benchmarks

  • GET /api/benchmarks/{gitref}

    • List all benchmarks for a specific git reference
    • Parameters:
      • gitref: Git reference identifier
    • Response: Array of benchmark summaries
  • GET /api/benchmarks/{hardware}/{gitref}

    • List benchmarks for specific hardware and git reference
    • Parameters:
      • hardware: Hardware configuration identifier
      • gitref: Git reference identifier
    • Response: Array of benchmark summaries

Benchmark Reports

  • GET /api/benchmark/full/{unique_id}

    • Get full benchmark report
    • Parameters:
      • unique_id: UUID of the benchmark
    • Response: Complete benchmark report JSON
  • GET /api/benchmark/light/{unique_id}

    • Get lightweight benchmark report
    • Parameters:
      • unique_id: UUID of the benchmark
    • Response: Simplified benchmark report JSON

Trend Analysis

  • GET /api/benchmark/trend/{hardware}/{params_identifier}
    • Get benchmark trend data
    • Parameters:
      • hardware: Hardware configuration identifier
      • params_identifier: Benchmark parameters identifier
    • Response: Array of benchmark data points for trend analysis

Test Artifacts

  • GET /api/benchmark/{unique_id}/artifacts
    • Download test artifacts for a benchmark
    • Parameters:
      • unique_id: UUID of the benchmark
    • Response: ZIP archive containing test artifacts

All endpoints return JSON responses (except artifacts which returns a ZIP file) and use standard HTTP status codes:

  • 200: Success
  • 404: Resource not found
  • 500: Server error

Prerequisites

  • Rust toolchain (latest stable)

    bash
    rustup default stable
  • WebAssembly target

    bash
    rustup target add wasm32-unknown-unknown
  • Trunk (for frontend development)

    bash
    cargo install trunk

Development Setup

  1. Clone the repository:

    bash
    git clone <repository-url>
  2. Run the development script:

    bash
    ./scripts/dashboard/run_dev.sh

    This will start both the frontend development server and the backend server.

Running the Application

Production Mode

  1. Build the release version:

    bash
    ./scripts/dashboard/build_release.sh
  2. Start the server:

    bash
    ./target/release/iggy-bench-dashboard-server --host 127.0.0.1 --port 8061

Development Mode

Run the development script from root of apache/iggy repository to start both the frontend and backend servers:

bash
./scripts/dashboard/run_dev.sh

This will start:

  • Frontend development server on port 8060
  • Backend API server on port 8061

Access the development version at http://localhost:8060

Running with Docker

The docker image is available, and can be fetched via docker pull apache/iggy-bench-dashboard.

Building the Image

Call this from the root of the repository:

bash
docker build -t iggy-bench-dashboard -f core/bench/dashboard/server/Dockerfile .

Running the Container

First, ensure your performance results directory exists and has proper permissions:

bash
mkdir -p performance_results
chmod 755 performance_results

Basic usage (recommended):

bash
docker run -p 8061:8061 \
   -v "$(pwd)/performance_results:/data/performance_results" \
   --user "$(id -u):$(id -g)" \
   apache/iggy-bench-dashboard

With custom configuration:

bash
docker run -p 8061:8061 \
      -v "$(pwd)/performance_results:/data/performance_results" \
      --user "$(id -u):$(id -g)" \
      -e HOST=0.0.0.0 \
      -e PORT=8061 \
      -e RESULTS_DIR=/data/performance_results \
      apache/iggy-bench-dashboard

Using a named volume:

bash
# Create a named volume
docker volume create iggy-results

# Run with named volume
docker run -p 8061:8061 \
   -v iggy-results:/data/performance_results \
   apache/iggy-bench-dashboard

Configuration

Docker Configuration

Environment Variables

Variable Default Description
HOST 0.0.0.0 Server host address
PORT 8061 Server port
RESULTS_DIR /data/performance_results Directory for performance results

Volume Permissions

The container is configured to run as a non-root user for security. When mounting a local directory, you should:

  1. Use the --user flag with your local user ID to ensure proper file permissions
  2. Make sure your local directory has the correct permissions (755)
  3. If using a named volume, the container will handle permissions automatically

Application Configuration

Server Settings

The server can be configured using command-line arguments:

bash
iggy-bench-dashboard-server [OPTIONS]

Options:
      --host <HOST>                  Server host address [default: 127.0.0.1]
      --port <PORT>                  Server port [default: 8061]
      --results-dir <RESULTS_DIR>    Directory containing performance results [default: ./performance_results]
      --log-level <LOG_LEVEL>        Log level (trace, debug, info, warn, error) [default: info]
      --cors-origins <CORS_ORIGINS>  Allowed CORS origins (comma-separated) [default: *]
  -h, --help                         Print help
  -V, --version                      Print version

Environment Variables for Development

For development, you can also use environment variables:

  • RUST_LOG: Control log level and filters
  • RUST_BACKTRACE: Enable backtraces (1 = enabled, full = full backtraces)

License

Apache-2.0


Overview (`core/connectors/runtime/README.md`)

Apache Iggy Connectors - Runtime

Runtime is responsible for managing the lifecycle of the connectors and providing the necessary infrastructure for the connectors to run.

The runtime uses a shared Tokio runtime for its connector-management and forwarding tasks. Each loaded plugin library also has an SDK Tokio runtime shared by its instances. Additionally, it has built-in support for logging via tracing crate.

The connector are implemented as Rust libraries, and these are loaded dynamically during the runtime initialization process.

Internally, dlopen2 loads plugin libraries and resolves their C FFI symbols. Plugins execute inside the runtime process.

By default, runtime will look for the configuration file, to decide which connectors to load and how to configure them.

Set the broker credentials and connector configuration directory before starting the runtime. The embedded default has an empty connector directory and cannot start unchanged. Follow the connector quick start for a complete setup.

The docker image is available, and can be fetched via docker pull apache/iggy-connect:edge.

The runtime opens two Iggy TCP clients, one for producers and one for consumers. Set credentials matching the broker and a connector configuration provider. Omitted settings use the embedded defaults, including file-based source state storage. Save this example as connectors.toml in the repository root and replace path/to/connectors with your connector configuration directory.

toml
[iggy]
address = "localhost:8090"
username = "iggy"
password = "iggy"
token = "" # Personal Access Token (PAT) can be used instead of username and password

[iggy.tls] # Optional TLS configuration for Iggy TCP connection
enabled = false
ca_file = "core/certs/iggy_ca_cert.pem"
domain = "" # Optional domain for TLS connection

[state]
path = "local_state"

[connectors]
config_type = "local"
config_dir = "path/to/connectors"

[logging] # Optional logging configuration
format = "text" # Options: "text" (default), "json"

Start it from the repository root:

bash
IGGY_CONNECTORS_CONFIG_PATH=connectors.toml cargo run --bin iggy-connectors

Supported scalar fields and indexed list entries use environment variables with nested keys joined by underscores, for example IGGY_CONNECTORS_IGGY_USERNAME. Header and URL-template maps are configured in TOML. The runtime loads the first .env file found in the working directory or its parents, or the file specified by IGGY_CONNECTORS_ENV_PATH.

Run iggy-connectors --list-config-env-vars to print the supported names and templates without loading configuration or plugins. <N> is a stream-vector index from 0 through 255. <KEY> is the uppercased connector key and applies only to the local provider.

<FIELD> sets one lowercased top-level plugin_config key with underscores preserved (A_B becomes a_b, not nested a.b); nested plugin configuration keys cannot be set through environment variables. FORMAT is reserved and listed separately as ..._PLUGIN_CONFIG_FORMAT.

Source destination topics must persist every acknowledged batch before the runtime checkpoints the source or invokes its Ack hook. Missing topics are therefore created with durability = "persisted" and messages_required_to_save = 1. An existing topic must use durability = "persisted"; its save threshold may differ because persisted acknowledgments already wait for durable storage.

State storage

Source plugins can supply optional checkpoint bytes. The runtime stores these opaque bytes using the backend selected by state.storage:

  • file (default): one file at {state.path}/source_{key}.state for each source that supplies checkpoints. Writes use a temporary file, file synchronization and atomic rename. On Unix, the parent directory is synchronized too. Ties the cursor to the local disk.
  • http: one resource per source at {state.http.url}/source_{key} on any HTTP-speaking store (a sidecar in front of a database, an object-store gateway, a coordination service). A replacement runtime can read the same checkpoint from that state server; its durability and availability depend on the server.
toml
[state]
path = "local_state"      # used by storage = "file"
storage = "http"          # "file" | "http"

[state.http]
url = "http://127.0.0.1:8080/connectors/state"
load_method = "get"       # "get" (default) | "post"
save_method = "put"       # "put" (default) | "post" | "patch"
timeout = "5s"

# Static headers attached verbatim to every request. Carries authentication
# (e.g. a bearer token) and any deployment-specific metadata the server
# wants. The runtime never interprets them and never logs their values.
[state.http.request_headers]
authorization = "Bearer ..."

[state.http.retry]
enabled = true
max_attempts = 4          # retries per logical operation, after the first try
initial_backoff = "200ms"
max_backoff = "2s"
backoff_multiplier = 2

Everything except request_headers is also env-addressable: IGGY_CONNECTORS_STATE_STORAGE, IGGY_CONNECTORS_STATE_HTTP_URL, IGGY_CONNECTORS_STATE_HTTP_TIMEOUT, IGGY_CONNECTORS_STATE_HTTP_RETRY_MAX_ATTEMPTS, and so on.

The configured base URL may contain a query string, which is preserved when source_{key} is appended to the path. Custom request headers are accepted except for If-Match, If-None-Match, Idempotency-Key, and Content-Type, which are owned by the state protocol.

storage = "http" with a missing or invalid url is a fatal startup error. With the HTTP backend, any state-load failure other than a clean 404 while starting an enabled source also fails startup: the store is unhealthy, and treating the failure as "no state" would silently rewind the source.

HTTP backend semantics

  • Every read uses the configured load_method and remembers the returned ETag. Every write uses the configured save_method and is conditional: If-Match: <etag> when a version is tracked, If-None-Match: * for the first-ever write. There is no unconditional overwrite path.
  • Every write carries an Idempotency-Key header, minted once per logical save and reused byte-identically across that save's retries, so a server that committed a write but lost the response can replay the original outcome instead of failing the retry with a spurious 412.
  • State is sent and returned as opaque bytes. The SDK provides MessagePack helpers. Writes use the bytes unchanged with Content-Type: application/octet-stream. The runtime never converts connector state to JSON, so each source retains its own compact state schema.
  • 425/429/503/5xx, timeouts and connect failures are retried with exponential backoff (honoring Retry-After, capped at max_backoff) and classified transient when exhausted: the batch is Nacked and the plugin re-polls.
  • 412/409 (version conflict), 401/403 (authorization lost) and protocol violations are permanent: the provider latches and every later save fails fast without touching the network, until the connector is restarted. A permanent error means another writer took over or this writer's authority was revoked - retrying cannot help and would mask the original error.
  • Durability is the server's durability. The runtime guarantees only that the checkpoint is not advanced (the batch is not Acked) unless the server confirmed the write.

Implementing a state server

Any HTTP server can back the state endpoint if it meets this contract:

  1. The configured load method, GET by default, at {url}/source_{key} returns 200 + strong ETag + the stored bytes, or 404 if the key has never been written. If the value exists but cannot be served consistently yet, return 425 or 503 (optionally with Retry-After) - never 404.
  2. The configured save method, PUT by default, at {url}/source_{key} must enforce If-Match/If-None-Match atomically against the stored version and return 412 on mismatch. Success responses (200/201/204) carry the new ETag. Requests without a conditional header may be rejected with 428 Precondition Required.
  3. Idempotency-Key: remember each key's outcome for at least the client's retry horizon and replay it on repeats. Replays must not re-apply the write.
  4. ETags are strong and change on every committed write. The runtime treats them as opaque strings: stored verbatim, sent back verbatim.
  5. Authentication is the server's business, transported via the configured static headers; failures are 401/403. Treat 403 as authoritative revocation, not a transient state.

This contract lets a deployment back the endpoint with a replicated store and revoke a stale writer's authority mid-flight: the stale writer's next conditional PUT fails 403/412, its provider latches, and its pipeline stops advancing state - no backend-specific knowledge in the runtime.

The runtime does not delete remote state. Disabling, removing, or restarting a source preserves its checkpoint. Deletion is an explicit administrative action on the backing store because automatic deletion would make a connector lifecycle operation silently rewind the source.

Logging

By default, the runtime emits human-readable text logs via the tracing crate. Switching logging.format to json produces structured JSON lines (one event per line, machine-parseable). When telemetry is enabled, the chosen format applies to the local stdout layer; OpenTelemetry export is unaffected.

toml
[logging]
format = "json"

RUST_LOG controls verbosity and per-target filtering (e.g. RUST_LOG=info,iggy_connectors::benchmark=info).

Configuration Providers

The runtime supports two types of configuration providers for managing connector configurations:

Local File Provider

The default configuration provider reads connector configurations from local files. Each connector (source or sink) is configured in its own separate file within the directory specified by connectors.config_dir. An empty config_dir is a fatal startup error. A missing directory is created automatically with a warning, and no connectors are loaded from it. Only nonhidden *.toml files directly inside the directory are read; Cargo.toml is skipped.

toml
[connectors]
config_type = "local"
config_dir = "path/to/connectors"

HTTP Configuration Provider

The HTTP configuration provider allows the runtime to fetch connector configurations from a remote HTTP/REST API. The provider fetches active configurations at startup and handles configuration operations requested through the runtime API. It does not periodically poll for remote changes.

toml
[connectors]
config_type = "http"
base_url = "http://localhost:8080/api"
timeout = "10s"

[connectors.request_headers]
api-key = "your-api-key"

[connectors.retry]
enabled = true
max_attempts = 3 # Retries after the first request, up to four requests total
initial_backoff = "1 s"
max_backoff = "30 s"
backoff_multiplier = 2

[connectors.url_templates]
# Optional: Customize URL templates for specific operations
# If not specified, default RESTful URL patterns are used
create_sink = "/sinks/{key}/configs"
create_source = "/sources/{key}/configs"
get_active_configs = "/configs/active"

[connectors.response]
# Optional: Extract data from nested response structures
data_path = "data"        # Path to data in response (e.g., {"data": {...}})
error_path = "error"      # Path to error in response (e.g., {"error": "..."})

Configuration Options

  • base_url (required): Base URL of the configuration API endpoint
  • timeout (optional): HTTP request timeout (default: 10s)
  • request_headers (optional): Custom headers to include in all HTTP requests (e.g., authentication headers)
  • url_templates (optional): Custom URL templates for API endpoints. Supports variable substitution with {key} and {version} placeholders.
  • response.data_path (optional): Dot-separated object keys or numeric array indexes used to extract data (e.g., data.config or data.0).
  • response.error_path (optional): A path with the same syntax. Any non-null value at this path is treated as an error, including false or an empty string.

Default URL Templates

If not customized, the HTTP provider uses the following RESTful URL patterns:

  • Create sink config: POST {base_url}/sinks/{key}/configs
  • Create source config: POST {base_url}/sources/{key}/configs
  • Get active configs: GET {base_url}/configs/active
  • Get active versions: GET {base_url}/configs/active/versions
  • Set active sink version: PUT {base_url}/sinks/{key}/configs/active
  • Set active source version: PUT {base_url}/sources/{key}/configs/active
  • Get sink configs: GET {base_url}/sinks/{key}/configs
  • Get sink config by version: GET {base_url}/sinks/{key}/configs/{version}
  • Get active sink config: GET {base_url}/sinks/{key}/configs/active
  • Get source configs: GET {base_url}/sources/{key}/configs
  • Get source config by version: GET {base_url}/sources/{key}/configs/{version}
  • Get active source config: GET {base_url}/sources/{key}/configs/active
  • Delete sink config: DELETE {base_url}/sinks/{key}/configs
  • Delete source config: DELETE {base_url}/sources/{key}/configs

The HTTP provider expects the remote API to implement these endpoints and return connector configuration data in the same format as used by the local provider.

HTTP API

The HTTP API is enabled by default at 127.0.0.1:8081. Set [http].enabled = false to disable it.

toml
[http] # Optional HTTP API configuration
enabled = true
# Loopback on purpose: the configuration endpoints return plugin credentials in
# plaintext and also accept writes. Set api_key in the same edit if you move
# this off loopback, and http.tls unless cleartext is acceptable.
address = "127.0.0.1:8081"
api_key = "" # Optional API key for authentication to be passed as `api-key` header; empty disables authentication

[http.cors] # Optional CORS configuration for HTTP API
# Enabling this with the shipped allowed_origins = ["*"] lets any page the
# operator visits read these endpoints cross-origin, whatever address is bound.
# Pin the origins you own, or set api_key, before turning it on.
# "*" is honored only as the first entry; anywhere else it panics at startup.
enabled = false
allowed_methods = ["GET", "POST", "PUT", "DELETE"]
allowed_origins = ["*"]
allowed_headers = ["content-type"]
exposed_headers = [""]
allow_credentials = false
allow_private_network = false

[http.metrics] # Optional Prometheus metrics configuration
enabled = false
endpoint = "/metrics"

[http.tls] # Optional TLS configuration for HTTP API
enabled = false
cert_file = "core/certs/iggy_cert.pem"
key_file = "core/certs/iggy_key.pem"

[!IMPORTANT]
Treat this API as privileged. It reads and it writes.

The configuration endpoints return plugin configuration exactly as stored,
credentials included - a database connection string, an S3 secret key, a
webhook signing secret. Nothing redacts them on the way out. The runtime
masks the api-key in its own logs and redacts the state-store headers when
it serializes them, but no such path exists for plugin configuration.

The exposure is not limited to disclosure. Publishing a configuration with
POST /{sinks,sources}/{key}/configs and then calling POST .../restart is
enough to repoint a connector at a destination of the caller's choosing,
because restart re-reads the stored configuration and starts the connector
from it - on the local provider that is whatever version was published last,
with no activation step in between. The runtime then forwards the operator's
topic data using its own Iggy credentials. PUT .../configs/active and
DELETE .../configs sit behind the same key.

A rewritten plugin path is not loaded by the restart. start_connector
reuses the container dlopened at boot and only re-runs the plugin's init
with the new configuration, so a hostile path sits in the stored config until
the next time the runtime process starts, which makes it deferred code
execution rather than immediate.

api_key is empty by default, which means authentication is off by
default. Only / and /health are exempt once it is set, so everything above
sits behind that one empty string, and the loopback default address is what
confines it to local processes.

Three edits take that containment away:

  • Moving address off loopback. Set api_key in the same edit. The
    runtime warns at startup when the address resolves beyond loopback with no
    key configured, but nothing prevents it.

  • Enabling [http.cors] with the shipped allowed_origins = ["*"]. That
    becomes AllowOrigin::any(), and the CORS layer wraps outside
    authentication, so tower-http answers the preflight before resolve_api_key
    runs. A browser is a local process, so with a wildcard origin and no key any
    page the operator visits gets a green preflight for a configuration POST
    and can then read and rewrite configuration cross-origin - the whole
    publish-then-restart repoint above, not just disclosure. The rewrite half
    needs the method on allowed_methods and content-type on
    allowed_headers, both of which the shipped block grants.

    Setting api_key closes it, since an attacker's page cannot supply the
    header. Pinning allowed_origins to origins the operator owns closes the
    cross-origin read, and no startup warning fires for that case. One fires
    for null, which is narrower than * but not owned by anyone: a browser
    sends it from a sandboxed iframe, a data: URL and a file:// page, all of
    which an attacker can produce, so pinning it buys nothing. No origin setting
    closes the CORS-simple route below; api_key does.

  • Leaving http.tls.enabled = false. It ships disabled, so the api-key
    header and the credential-bearing responses both travel in cleartext. Enable
    TLS alongside api_key whenever this API leaves loopback. The runtime warns
    at startup whenever the address resolves beyond loopback with TLS off, and
    keeps warning after api_key is set, because the key crosses in the clear
    too. Terminating TLS at an ingress or a service mesh is a valid answer to
    that warning; the runtime cannot see it, so the line stays.

And one that needs no edit at all. POST .../restart carries no body and no
content type, which makes it a CORS-simple request: a page can issue it with
mode: 'no-cors' and the browser sends it whatever [http.cors] says,
because CORS gates reading a response rather than issuing a request. So on the
shipped keyless default, any page the operator visits can restart any
connector, and no startup warning covers it because it is true of the defaults
rather than of an edit. Chrome's private network access blocks the
public-origin case; Firefox and Safari do not, a page served from a local
origin bypasses it everywhere, and setting allow_private_network = true
hands back the case Chrome would otherwise block. api_key is what closes
this one.

Currently, it does expose the following endpoints:

  • GET /: welcome message.
  • GET /health: process liveness response. It does not check connector health; inspect /stats, /sources or /sinks for connector status.
  • GET /stats: runtime statistics including process info, memory/CPU usage, and connector status.
  • GET {http.metrics.endpoint} (default /metrics): Prometheus-formatted metrics, when http.metrics.enabled is true.
  • GET /sinks: list of sinks.
  • GET /sinks/{key}: sink details.
  • GET /sinks/{key}/configs: list of configuration versions for the sink.
  • POST /sinks/{key}/configs: add a new configuration version for the sink.
  • DELETE /sinks/{key}/configs: delete one configuration version for the sink - the version query parameter, or on the local provider the active version when it is omitted.
  • GET /sinks/{key}/configs/{version}: configuration details for a specific version.
  • GET /sinks/{key}/configs/active: active configuration details.
  • PUT /sinks/{key}/configs/active: activate a specific configuration version for the sink.
  • GET /sinks/{key}/configs/plugin: sink plugin config, including the optional format query parameter to specify the config format.
  • POST /sinks/{key}/restart: stop the sink and start it again from its highest stored configuration version, which on the local provider is not necessarily the active one (#3848).
  • GET /sinks/{key}/transforms: sink transforms to be applied to the fields.
  • GET /sources: list of sources.
  • GET /sources/{key}: source details.
  • GET /sources/{key}/configs: list of configuration versions for the source.
  • POST /sources/{key}/configs: add a new configuration version for the source.
  • DELETE /sources/{key}/configs: delete one configuration version for the source - the version query parameter, or on the local provider the active version when it is omitted.
  • GET /sources/{key}/configs/{version}: configuration details for a specific version.
  • GET /sources/{key}/configs/active: active configuration details.
  • PUT /sources/{key}/configs/active: activate a specific configuration version for the source.
  • GET /sources/{key}/configs/plugin: source plugin config, including the optional format query parameter to specify the config format.
  • POST /sources/{key}/restart: stop the source and start it again from its highest stored configuration version, which on the local provider is not necessarily the active one (#3848).
  • GET /sources/{key}/transforms: source transforms to be applied to the fields.

{key} is the connector key: at most 128 bytes of ASCII letters, digits, -,
_ and ., starting with a letter or digit. A decoded segment outside that
rule, such as ..%2F..%2Fpwned, is answered with 400 Bad Request and the
error code invalid_connector_key before the request reaches the configuration
provider, because on the local provider the key becomes part of a filename under
config_dir, and on the HTTP provider part of a URL. An unencoded / splits
the path and matches no route, so it is a 404.

Keys loaded from configuration files or the HTTP provider are not rejected, so
existing deployments keep starting, but a key outside the rule is logged at
startup and cannot be addressed through the API. Keys are case-sensitive to the
runtime while the local provider maps them to filenames, so on a
case-insensitive filesystem two keys that differ only by case share one file;
prefer lowercase.

Telemetry

The connector runtime supports OpenTelemetry for logs and traces. To enable telemetry, add the following configuration:

toml
[telemetry]
enabled = true
service_name = "iggy-connectors"

[telemetry.logs]
transport = "grpc" # Options: "grpc", "http"
endpoint = "http://localhost:4317"

[telemetry.traces]
transport = "grpc" # Options: "grpc", "http"
endpoint = "http://localhost:4317"

For transport = "http", use complete signal endpoints such as http://localhost:4318/v1/logs and http://localhost:4318/v1/traces. The runtime does not append those paths.

Benchmark Mode

Each connector configuration accepts an optional benchmark flag. When set to true, the runtime emits a per-batch info! event on the iggy_connectors::benchmark tracing target with stage timings in microseconds. This is opt-in and adds a single tracing call per processed batch.

Set the flag before any section headers in an existing connector configuration:

toml
benchmark = true

Emitted fields:

  • sink: connector_key, stream, topic, partition_id, current_offset, batch_size, processed_count, runs, decode_us, prepare_us, ffi_us, total_us. runs is the number of consume() calls the batch was split into, one per contiguous payload variant, and ffi_us is summed over all of them.
  • source: connector_key, stream, topic, batch_size, sent_count, decode_us, prepare_us, iggy_send_us, state_saved, state_save_us, total_us (state_save_us is 0 when state_saved is false)

Filter the stream via RUST_LOG=iggy_connectors::benchmark=info. The corresponding stage durations are also recorded in the iggy_connector_stage_duration_seconds histogram regardless of this flag, so Prometheus dashboards remain available without enabling text events.

Metrics

The runtime exposes Prometheus-compatible metrics via the /metrics endpoint when enabled. The following metrics are available:

Runtime Gauges

  • iggy_connectors_sources_total: Total configured source connectors
  • iggy_connectors_sources_running: Sources currently in Running status
  • iggy_connectors_sinks_total: Total configured sink connectors
  • iggy_connectors_sinks_running: Sinks currently in Running status

Per-Connector Counters (labeled with `connector_key` and `connector_type`)

  • iggy_connector_messages_produced_total: Messages received from source plugin
  • iggy_connector_messages_sent_total: Messages sent to Iggy (source)
  • iggy_connector_messages_consumed_total: Messages consumed from Iggy (sink)
  • iggy_connector_messages_processed_total: Messages processed and sent to sink plugin
  • iggy_connector_messages_filtered_total: Messages intentionally dropped by transforms returning Ok(None)
  • iggy_connector_errors_total: Errors encountered
  • iggy_connector_sink_runs_total: FFI consume() calls made for sink batches. A batch is split into one call per contiguous payload variant, so this counter over iggy_connector_stage_duration_seconds_count{stage="total"} is the average number of runs per batch, and it equals that count when no batch splits

Stage Duration Histograms (labeled with `connector_key`, `connector_type`, `stage`)

  • iggy_connector_stage_duration_seconds: Per-batch processing stage duration

Stage label values (snake_case): decode, prepare, total on both sides; ffi on sinks; iggy_send, state_save on sources. The connector_type label is also snake_case (source / sink). Histograms are always exposed (independent of benchmark); buckets cover 50us to 5s.

Every stage records one sample per batch. The ffi sample is the summed duration of every consume() call the batch was split into, so a batch that split into several runs still counts once; iggy_connector_sink_runs_total shows how often that happens.

Cardinality: each (connector_key, stage) pair yields ~16 series (13 explicit buckets + +Inf + _sum + _count), times 3 stages per sink or 5 per source, times the number of distinct connector_key values. connector_key comes from operator config, so keep it bounded. Do not template per-user or per-request keys into it or the time-series database will blow up.

Stats

The /stats endpoint provides runtime and per-connector statistics in JSON format. This includes system resource usage, connector counts, and detailed per-connector metrics with version information.

Example Response

json
{
  "connectors_runtime_version": "0.3.0",
  "connectors_runtime_version_semver": 203,
  "process_id": 12345,
  "cpu_usage": 2.5,
  "total_cpu_usage": 15.3,
  "memory_usage": 52428800,
  "total_memory": 17179869184,
  "available_memory": 8589934592,
  "run_time": 3600000000,
  "start_time": 1706889600000000,
  "sources_total": 1,
  "sources_running": 1,
  "sinks_total": 1,
  "sinks_running": 1,
  "connectors": [
    {
      "key": "random-source",
      "name": "Random Source",
      "connector_type": "source",
      "version": "0.3.0",
      "version_semver": 202,
      "status": "running",
      "enabled": true,
      "messages_produced": 1000,
      "messages_sent": 1000,
      "messages_filtered": 0,
      "errors": 0
    },
    {
      "key": "stdout-sink",
      "name": "Stdout Sink",
      "connector_type": "sink",
      "version": "0.3.0",
      "version_semver": 202,
      "status": "running",
      "enabled": true,
      "messages_consumed": 1000,
      "messages_processed": 1000,
      "messages_filtered": 0,
      "errors": 0
    }
  ]
}

Overview (`core/connectors/sdk/README.md`)

Apache Iggy Connectors - SDK

SDK provides the commonly used structs and traits such as Sink and Source, along with the sink_connector! and source_connector! macros to be used when developing connectors.

The macros automatically export the connector's version (from CARGO_PKG_VERSION) via FFI, allowing the runtime to report per-connector version information in the /stats endpoint.

Source delivery acknowledgment

Source connectors use a one-in-flight-batch contract between the plugin and the runtime:

  1. Source::poll() returns messages and candidate state without committing cursor changes or destructive operations.
  2. The runtime sends the batch to Iggy and waits for the producer result.
  3. After a successful send, the runtime persists the candidate state if the batch provides it.
  4. The runtime reports SourceBatchResult::Ack to the plugin. A send or state-save failure reports SourceBatchResult::Nack instead.
  5. Source::on_batch_result() commits or discards the plugin's staged work before the next poll starts.

An empty batch follows the same handshake. A source should return state: None when an empty poll made no progress; this avoids an unnecessary state write and cannot persist state left over from a failed batch. Producer errors, including request timeouts, report a NACK. A successful send from the legacy Iggy server is still an ACK even though that server returns an empty confirmation list.

The runtime resolves a send request with an unknown outcome before it sends anything again. When the resolve proves that the request committed, the runtime resends only the messages after that request. Otherwise it reports a NACK, so a replay of the batch can deliver the messages of that request again.

The crash behavior is intentionally at-least-once:

Crash point Recovery behavior
Before Iggy commits the batch Persisted state is unchanged and the source can poll the batch again.
After Iggy commits but before the runtime observes success Persisted state is unchanged, so the batch may be delivered again.
After send success but before state persistence Persisted state is unchanged, so the batch may be delivered again.
After state persistence but before the plugin processes the ACK The restored state records the delivered batch. Deferred source-side cleanup may still be pending.
After the plugin processes the ACK The state and plugin cursor both record the delivered batch.
After a replicated confirmation and source cleanup, but before stable storage Losing the replicas holding the unpersisted tail can lose the batch. Create the topic with durability=persisted when source cleanup requires durable quorum confirmation.

An ACK follows Iggy's quorum confirmation. The topic's durability policy decides whether that confirmation also waits for stable storage on the quorum. Both policies normally write messages to disk.

Source-side ACK work should be idempotent because process termination can interrupt it. NACK handling must discard staged cursor changes and staged delete or mark operations so polling can redeliver the batch. The SDK retries NACKed batches with capped exponential backoff and stops after five consecutive NACKs by default.

A source whose accepted input cannot be re-read after a restart can override Source::batch_policy() and call BatchPolicy::with_max_consecutive_nacks(None) to keep retrying until shutdown. The SDK warns after 30 seconds without a batch result, but keeps that batch in flight until the runtime replies or the source shuts down.

The source receives only ACK or NACK, not the runtime's failure cause. It cannot classify a NACK as transient. Disable the limit only when repeated replay is safer than stopping; a permanent failure still needs operator attention. If on_batch_result() returns an error, polling stops because its staged-work outcome is unknown.

The default Source::on_batch_result() implementation is a no-op for sources without staged work. Sources that advance cursors, delete rows, or mark rows must override it.

SDK 0.5.1-edge.2 adds Source::batch_policy() with a default implementation, so existing source implementations need no code change when rebuilt. Sources that opt out of the NACK breaker must retain and replay their rejected batch; otherwise disabling the stop only turns a visible failure into a silent drop.

Rebuild source plugins to export iggy_source_register_stop_callback; the runtime warns when an older plugin lacks it. The callback reports why polling stopped so the runtime can mark the source Error and update its running gauge.

The original batch-acknowledgment contract introduced a breaking FFI change. Source plugins built before it must be rebuilt with the matching SDK. The runtime loads iggy_source_handle_v2, which supplies a batch ID to the runtime callback, and source plugins export iggy_source_batch_result for the corresponding ACK or NACK. SDK 0.5.1-edge.2 also adds the optional stop-callback export; older plugins cannot report an unexpected poll-task exit.

Moreover, it contains both, the decoders and encoders modules, implementing either StreamDecoder or StreamEncoder traits, which are used when consuming or producing data from/to Iggy streams.

The SDK provides decoders and encoders for JSON, raw bytes, text, Protocol Buffers, FlatBuffers, and Avro. Their supported conversions have format-specific limits; see below and the transforms guide.

Last but not least, the different transforms are available, to transform (add, update, delete etc.) the particular fields of the data being processed via external configuration. It's as simple as adding a new transform to the transforms section of the particular connector configuration file:

toml
[transforms.add_fields]
enabled = true

[[transforms.add_fields.fields]]
key = "message"
value.static = "hello"

Retry helpers

retry_async runs an operation that fails with Err and retries it while should_retry accepts the error. It owns attempt counting, backoff and the per-retry log, and returns RetryFailure { error, attempts, exhausted } so the caller logs the terminal failure. retry_backoff computes a single delay for a loop that cannot use retry_async, such as HttpRetryMiddleware, which retries on an Ok response rather than an Err. Its retry argument is 1-based.

HttpRetryMiddleware reads a server's Retry-After header on every status it retries, which is 429 and any 5xx. The header can lengthen the computed backoff but never shorten it, so a zero or a short value keeps the backoff. max_delay bounds only the computed backoff. The header has its own one-hour ceiling, and a value past it is drawn from the jitter window just below it. Only the integer-seconds form is read, so an HTTP-date or unparsable value falls back to the computed backoff.

Two symbols were removed and one field changed meaning, each in a way that breaks out-of-tree plugins, so those plugins must be rebuilt against the current source:

Removed Replacement
ConnectivityConfig RetryPolicy. max_open_retries becomes max_attempts, retry_delay becomes base_delay, and open_retry_max_delay becomes max_delay.
jitter (was public) retry_backoff, which applies the jitter itself.

Both types carry (u32, Duration, Duration) and the two delay roles cross over, so a field-by-field rename compiles and swaps the base delay for the cap. Map the fields by name.

MessagesMetadata.schema and RawMessages.schema now name the variant the Payload holds rather than the wire format the stream's decoder reads. Schema::Proto means a Payload::Proto string on the sink path where it meant protobuf wire bytes before, so a plugin built against 0.4.0 rebuilds the run as the wrong variant with no error anywhere.

Protocol Buffers Support

The SDK includes support for Protocol Buffers (protobuf) format with both encoding and decoding capabilities. Protocol Buffers provide efficient serialization and are particularly useful for high-performance data streaming scenarios.

Configuration Example

This example uses the Random source and Stdout sink from the matching checkout. Start a server using the getting-started guide, then build the plugins, runtime, and CLI from the repository root:

bash
cargo build --release -p iggy_connector_random_source -p iggy_connector_stdout_sink -p iggy-connectors -p iggy-cli
mkdir -p connectors

The source's schema = "proto" selects the default protobuf encoder, which wraps each JSON record in a google.protobuf.StringValue inside google.protobuf.Any. The sink reads raw bytes and applies proto_convert to expose the Any envelope as JSON. This does not unpack a custom protobuf message schema.

Main runtime config (connectors.toml):

toml
[iggy]
address = "localhost:8090"
username = "iggy"
password = "iggy"

[connectors]
config_type = "local"
config_dir = "connectors"

Source connector config (connectors/protobuf_source.toml):

toml
type = "source"
key = "protobuf"
enabled = true
version = 0
name = "Protobuf Source"
path = "target/release/libiggy_connector_random_source"

[[streams]]
stream = "protobuf_stream"
topic = "protobuf_topic"
schema = "proto"
batch_length = 1000
linger_time = "5ms"

[plugin_config]
interval = "100ms"
messages_range = [1, 10]
payload_size = 32
max_count = 100

Sink connector config (connectors/protobuf_sink.toml):

toml
type = "sink"
key = "protobuf"
enabled = true
version = 0
name = "Protobuf Sink"
path = "target/release/libiggy_connector_stdout_sink"

[[streams]]
stream = "protobuf_stream"
topics = ["protobuf_topic"]
schema = "raw"

[plugin_config]
print_payload = true

[transforms.proto_convert]
enabled = true
source_format = "proto"
target_format = "json"
include_paths = ["."]
preserve_unknown_fields = false

[transforms.proto_convert.conversion_options]
validate_messages = true
pretty_json = false
include_metadata = false
type_url_prefix = "type.googleapis.com"
strict_mode = false

Create the stream and topic, then start the runtime from the repository root:

bash
./target/release/iggy --username iggy --password iggy stream create protobuf_stream
./target/release/iggy --username iggy --password iggy topic create protobuf_stream protobuf_topic 1 none 1d
IGGY_CONNECTORS_CONFIG_PATH=connectors.toml ./target/release/iggy-connectors

The source sends 100 records, then continues polling without new messages. Stdout logs message offsets and the serialized JSON envelope bytes, containing type_url and base64 value. The batch handed to the plugin is tagged with the payload's own schema rather than the stream's configured raw: proto_convert leaves a Payload::Json, so the plugin receives a json batch.

The format-conversion transforms define no per-key defaults. Every non-optional key shown above must be present, or the configuration fails to deserialize (schema_path, message_type, field_mappings, and descriptor_set are optional).

The two [[streams]] shapes differ: a source produces to a single topic and can tune batching via batch_length and linger_time, while a sink consumes from a list of topics and can additionally set batch_length, poll_interval, and consumer_group.

Transforms are keyed by type, so one connector configures at most one proto_convert.

The order of a transform chain is not defined. The runtime builds the chain from a map keyed by transform type, so two transforms on one connector can run in either order from one process to the next. Do not configure a chain whose result depends on which transform runs first.

Key Configuration Options

Programmatic Encoder and Decoder Configuration

These are SDK configuration fields, not Random or Stdout plugin_config keys. The runtime's schema = "proto" uses the default encoder or decoder.

  • schema_path: Path to the .proto file containing message definitions
  • message_type: Fully qualified name of the protobuf message type to use
  • use_any_wrapper: Selects the Any fallback when no message descriptor is loaded; a loaded descriptor takes precedence

Transform Options

  • proto_convert: Transform for converting between protobuf and other formats
  • source_format / target_format: Formats to convert between - any schema value (json, raw, text, proto, flat_buffer, avro). Only flatbuffer_convert checks source_format against the payload it was handed and rejects a mismatch. proto_convert and avro_convert dispatch on the format pair with no up-front guard, which is why the schema = "raw" plus source_format = "proto" example above works
  • preserve_unknown_fields: Accepted by proto_convert, but currently has no effect
  • include_paths: Additional directories searched for imported .proto files
  • field_mappings: Renames fields in a JSON input object before conversion (e.g., "old_field" = "new_field")
  • conversion_options: pretty_json controls JSON text output and include_metadata enriches supported protobuf-to-JSON paths. validate_messages, type_url_prefix, and strict_mode are accepted but currently have no effect

The schema_registry_url field is reserved and currently not implemented. The SDK never contacts a schema registry, and schemas are loaded only from schema_path or descriptor_set.

  • unwrap_envelope: Extracts a nested JSON field and promotes it as the top-level payload.
    Required when a source emits envelope-wrapped records (with metadata fields alongside a nested
    data object) and the downstream sink expects flat JSON matching the target table schema.
    • field: The envelope key whose value becomes the new payload (e.g., "data"). Must not be empty.
toml
[transforms.unwrap_envelope]
enabled = true
field = "data"

Supported Features

  • Encoding: A loaded message descriptor encodes matching JSON fields. The encoder supports booleans, strings, all protobuf integer types, and base64 strings for bytes or already-encoded nested messages. Float, double, and enum fields are unsupported by the encoder; nested JSON objects, repeated fields, maps, and proto2 groups are not a general-purpose schema conversion path.
  • Decoding: A loaded descriptor extracts present fields. Integer, floating-point, boolean, and string fields become JSON values; bytes become base64 and nested messages become metadata with base64 content. Missing fields are not filled with protobuf defaults. Without a descriptor, the default decoder returns an Any envelope's type_url and base64 value.
  • Transforms: proto_convert supports JSON-to-protobuf schema encoding for scalar fields, including floating-point numbers and numeric enum values; bytes and already-encoded nested messages use base64 strings. It logs and omits fields it cannot encode. Its protobuf-to-JSON path exposes Any metadata or raw-data metadata rather than decoding a custom message descriptor. Converting protobuf to flat_buffer or avro rewraps bytes without transcoding them.
  • Field Mapping: Encoder/decoder mappings use protobuf field names as keys and JSON field names as values. The encoder applies that mapping in reverse. Transform mappings rename JSON input keys directly.
  • Any Wrapper: The default encoder puts JSON/text in a google.protobuf.StringValue, or binary data in a google.protobuf.BytesValue, inside google.protobuf.Any. The default decoder exposes the envelope without unpacking its inner message.

Programmatic Usage

From the matching repository root, create an example crate and schema directory:

bash
mkdir -p connector-sdk-example/src schemas

Save this as connector-sdk-example/Cargo.toml. The path dependency uses the SDK from the same checkout as the runtime:

toml
[package]
name = "connector-sdk-example"
version = "0.1.0"
edition = "2024"

[dependencies]
iggy_connector_sdk = { path = "../core/connectors/sdk" }
simd-json = { version = "0.18.1", features = ["serde_impl"] }

[workspace]

Save this as schemas/user.proto:

protobuf
syntax = "proto3";
package com.example;

message User {
  uint64 id = 1;
  string name = 2;
}

Each Rust example below is a complete connector-sdk-example/src/main.rs. Run it from the repository root so the relative schema path resolves:

bash
cargo run --manifest-path connector-sdk-example/Cargo.toml

Dynamic Schema Loading

You can load or reload schemas programmatically:

rust
use iggy_connector_sdk::decoders::proto::{ProtoConfig, ProtoStreamDecoder};
use iggy_connector_sdk::encoders::proto::{ProtoEncoderConfig, ProtoStreamEncoder};
use iggy_connector_sdk::{Error, Payload, StreamDecoder, StreamEncoder};
use std::path::PathBuf;

fn main() -> Result<(), Error> {
    let mut decoder = ProtoStreamDecoder::new_default();
    decoder.update_config(
        ProtoConfig {
            schema_path: Some(PathBuf::from("schemas/user.proto")),
            message_type: Some("com.example.User".to_string()),
            ..ProtoConfig::default()
        },
        true,
    )?;
    let encoder = ProtoStreamEncoder::new_with_config(ProtoEncoderConfig {
        schema_path: Some(PathBuf::from("schemas/user.proto")),
        message_type: Some("com.example.User".to_string()),
        ..ProtoEncoderConfig::default()
    });
    let encoded = encoder.encode(Payload::Json(simd_json::json!({
        "id": 1,
        "name": "Alice"
    })))?;
    println!("{}", decoder.decode(encoded)?);
    Ok(())
}

The encoder follows the same pattern:

rust
use iggy_connector_sdk::encoders::proto::{ProtoEncoderConfig, ProtoStreamEncoder};
use iggy_connector_sdk::{Error, Payload, StreamEncoder};
use std::path::PathBuf;

fn main() -> Result<(), Error> {
    let mut encoder = ProtoStreamEncoder::new_with_config(ProtoEncoderConfig {
        schema_path: Some(PathBuf::from("schemas/user.proto")),
        message_type: Some("com.example.User".to_string()),
        use_any_wrapper: false,
        ..ProtoEncoderConfig::default()
    });
    encoder.load_schema()?;
    let encoded = encoder.encode(Payload::Json(simd_json::json!({
        "id": 1,
        "name": "Alice"
    })))?;
    println!("{encoded:?}");
    Ok(())
}

Creating Converters with Schema

The loaded schema is used for JSON-to-protobuf conversion. This example maps user_id and full_name to the schema's field names:

rust
use iggy_connector_sdk::transforms::{ProtoConvert, ProtoConvertConfig, Transform};
use iggy_connector_sdk::{DecodedMessage, Error, Payload, Schema, TopicMetadata};
use std::collections::HashMap;
use std::path::PathBuf;

fn main() -> Result<(), Error> {
    let converter = ProtoConvert::new(ProtoConvertConfig {
        source_format: Schema::Json,
        target_format: Schema::Proto,
        schema_path: Some(PathBuf::from("schemas/user.proto")),
        message_type: Some("com.example.User".to_string()),
        field_mappings: Some(HashMap::from([
            ("user_id".to_string(), "id".to_string()),
            ("full_name".to_string(), "name".to_string()),
        ])),
        ..ProtoConvertConfig::default()
    });
    let metadata = TopicMetadata {
        stream: "users".to_string(),
        topic: "users".to_string(),
    };
    let message = DecodedMessage {
        id: None,
        offset: None,
        checksum: None,
        timestamp: None,
        origin_timestamp: None,
        headers: None,
        payload: Payload::Json(simd_json::json!({
            "user_id": 1,
            "full_name": "Alice"
        })),
    };
    if let Some(converted) = converter.transform(&metadata, message)? {
        println!("{:?}", converted.payload);
    }
    Ok(())
}

Usage Notes

  • Automatic Loading: Constructors attempt to load schema_path or descriptor_set; schema_path takes precedence when both are set. Constructors log loading errors and return an instance without a loaded schema.
  • Manual Loading: load_schema() reloads the configured source. Missing or unreadable files, invalid protobuf syntax, compilation failures, and malformed descriptor bytes return errors and preserve an already-loaded schema. update_config(config, true) also restores the previous configuration on error; false changes the configuration while retaining the cached schema.
  • Fallbacks: Absent schema sources or an unmatched message_type can return Ok(()) without an active message descriptor. Successful reloads into fallback mode clear the previous descriptor. Check the actual encoded/decoded result when validating a schema setup.
  • Encoding Errors: Errors encoding a loaded message descriptor are returned to the caller. The encoder does not retry that message as Any. The decoder attempts Any after a schema decoding error.
  • Transform Configuration: Create a new converter to change its configuration. load_schema() can reload its existing source. Without a descriptor, JSON-to-protobuf conversion produces JSON text in Payload::Proto, not a schema-encoded binary message. With a descriptor it encodes a top-level JSON object to Payload::Raw and falls back to Payload::Proto for anything else, so one configured instance can return either variant depending on the message.
  • Two Schema Inverses: Schema::Proto means protobuf wire bytes when a source plugin sets ProducedMessages::schema, and a Payload::Proto string when the runtime tags a sink batch from Payload::schema(). Schema::try_into_payload reads the first and Payload::try_from_schema the second. Sink plugins use Payload::try_from_schema, which round-trips every variant; the source path keeps the Any-decoding arm.
  • Format Options: Encoder preserve_unknown_fields, compact_encoding, validate_message, and deterministic_encoding are accepted but have no effect. Decoder preserve_unknown_fields retains unknown varints as numbers and length-delimited data as base64; fixed-width unknown fields become placeholders. It does not retain the original wire encoding. See the Transforms page for conversion-specific limits.
  • Protocol Buffers provide efficient binary serialization compared to JSON

Overview (`core/connectors/sinks/README.md`)

Apache Iggy Connectors - Sink

Overview

Sink connectors are responsible for writing data from Iggy streams to external systems or destinations. They provide a way to integrate Apache Iggy with various data sources and destinations, enabling seamless data flow and processing.

Available Sinks

Sink Description
bigquery_sink Writes messages to Google BigQuery tables through the Storage Write API
doris_sink Loads JSON messages into Apache Doris tables via the Stream Load HTTP API
elasticsearch_sink Sends messages to Elasticsearch indices for full-text search and analytics
iceberg_sink Writes data to Apache Iceberg tables via REST catalog with S3/GCS/Azure storage
influxdb_sink Writes messages to InfluxDB as line-protocol points; supports both V2 (org/bucket, Flux) and V3 (db, SQL)
meilisearch_sink Indexes messages in Meilisearch for full-text search
postgres_sink Stores messages in PostgreSQL database tables with configurable schemas
quickwit_sink Indexes messages in Quickwit search engine for log analytics
redshift_sink Stores messages in Redshift warehouse tables with configurable schemas vis S3 as staging
s3_sink Writes messages to Amazon S3 and S3-compatible stores (MinIO, R2, B2, DO Spaces)
stdout_sink Prints messages to standard output (useful for debugging and development)
surrealdb_sink Writes messages into SurrealDB with deterministic record IDs for idempotent replay
rabbitmq_sink Publishes messages to RabbitMQ exchanges via AMQP

The sink is represented by the single Sink trait, which defines the basic interface for all sink connectors. It provides methods for initializing the sink, writing data to external destination, and closing the sink.

rust
#[async_trait]
pub trait Sink: Send + Sync {
    /// Invoked when the sink is initialized, allowing it to perform any necessary setup.
    async fn open(&mut self) -> Result<(), Error>;

    /// Invoked every time a batch of messages is received from the configured stream(s) and topic(s).
    async fn consume(
        &self,
        topic_metadata: &TopicMetadata,
        messages_metadata: MessagesMetadata,
        messages: Vec<ConsumedMessage>,
    ) -> Result<(), Error>;

    /// Invoked when the sink is closed, allowing it to perform any necessary cleanup.
    async fn close(&mut self) -> Result<(), Error>;
}

Configuration

Each sink connector is configured in its own separate configuration file within the connectors directory specified in the main runtime config.

rust
pub struct SinkConfig {
    pub key: String,
    pub enabled: bool,
    pub version: u64,
    pub name: String,
    pub path: String,
    pub transforms: Option<TransformsConfig>,
    pub streams: Vec<StreamConsumerConfig>,
    pub plugin_config_format: Option<ConfigFormat>,
    pub plugin_config: Option<serde_json::Value>,
    pub verbose: bool, // Log message processing at info level instead of debug (default: false)
    pub benchmark: bool, // Emit per-batch timing events on the `iggy_connectors::benchmark` target (default: false)
}

Main runtime config (config.toml):

toml
[connectors]
config_type = "local"
config_dir = "path/to/connectors"

Sink connector config (connectors/stdout_sink.toml):

toml
# Type of connector (sink or source)
type = "sink"
key = "stdout" # Unique sink ID

# Required configuration for a sink connector
enabled = true
version = 0
name = "Stdout sink"
path = "target/release/libiggy_connector_stdout_sink"
plugin_config_format = "toml"
verbose = false # Log message processing at info level instead of debug
benchmark = false # Emit per-batch timing events on `iggy_connectors::benchmark` target

# Collection of the streams from which messages are consumed
[[streams]]
stream = "example_stream"
topics = ["example_topic"]
schema = "json"
batch_length = 100
poll_interval = "5ms"
consumer_group = "stdout_sink_connector"

# Custom configuration for the sink connector, deserialized to type T from `plugin_config` field
[plugin_config]
print_payload = true

# Optional data transformation(s) to be applied after consuming messages from the stream
[transforms.add_fields]
enabled = true

# Collection of the fields transforms to be applied after consuming messages from the stream
[[transforms.add_fields.fields]]
key = "message"
value.static = "hello"

Environment Variable Overrides

Configuration properties can be overridden using environment variables. The pattern follows: IGGY_CONNECTORS_SINK_[KEY]_[PROPERTY]

For example, to override the enabled property for a sink with ID stdout:

bash
IGGY_CONNECTORS_SINK_STDOUT_ENABLED=false

Sample implementation

Let's implement the example sink connector, which will simply print the messages to the standard output.

Additionally, our sink connector will have its own state, which can be used e.g. to track the overall progress or store some relevant information when ingesting the data further to the external sources or tooling.

Also, when implementing the sink connector, make sure to use the sink_connector! macro to expose the FFI interface and allow the connector runtime to register the sink with the runtime.

And finally, each sink should have its own, custom configuration, which is passed along with the unique plugin ID via expected new() method.

Let's start by defining the internal state and the public sink connector along with its own configuration.

rust
#[derive(Debug)]
struct State {
    invocations_count: usize,
}
rust
#[derive(Debug)]
pub struct StdoutSink {
    id: u32,
    print_payload: bool,
    state: Mutex<State>
}
rust
#[derive(Debug, Serialize, Deserialize)]
pub struct StdoutSinkConfig {
    print_payload: Option<bool>,
}
rust
impl StdoutSink {
    pub fn new(id: u32, config: StdoutSinkConfig) -> Self {
        StdoutSink {
            id,
            print_payload: config.print_payload.unwrap_or(false),
            state: Mutex::new(State { invocations_count: 0 }),
        }
    }
}

We can invoke the expected macro to expose the FFI interface and allow the connector runtime to register the sink within the runtime.

rust
sink_connector!(StdoutSink);

At a bare minimum, we need to add the following dependencies to the Cargo.toml file to compile the plugin at all:

  • dashmap
  • tracing

Now, let's implement the Sink trait for our StdoutSink struct.

rust
#[async_trait]
impl Sink for StdoutSink {
    async fn open(&mut self) -> Result<(), Error> {
        info!(
            "Opened stdout sink connector with ID: {}, print payload: {}",
            self.id, self.print_payload
        );
        Ok(())
    }

    async fn consume(
        &self,
        topic_metadata: &TopicMetadata,
        messages_metadata: MessagesMetadata,
        messages: Vec<ConsumedMessage>,
    ) -> Result<(), Error> {
        let mut state = self.state.lock().await;
        state.invocations_count += 1;
        let invocation = state.invocations_count;
        drop(state);

        info!(
            "Stdout sink with ID: {} received: {} messages, schema: {}, stream: {}, topic: {}, partition: {}, offset: {}, invocation: {}",
            self.id,
            messages.len(),
            messages_metadata.schema,
            topic_metadata.stream,
            topic_metadata.topic,
            messages_metadata.partition_id,
            messages_metadata.current_offset,
            invocation
        );
        if self.print_payload {
            for message in messages {
                info!(
                    "Message offset: {}, payload: {:#?}",
                    message.offset, message.payload
                );
            }
        }
        Ok(())
    }

    async fn close(&mut self) -> Result<(), Error> {
        info!("Stdout sink connector with ID: {} is closed.", self.id);
        Ok(())
    }
}

It's also important to note, that the supported format(s) might vary depending on the connector implementation. For example, you might expect JSON as the payload format, which can be then easily parsed and processed by upstream components such as data transforms, but at the same time, you could support the other formats and let the user decide which one to use.

For example, you can match against the payload enum field containing the deserialized value to process (or not) the consumed message(s).

rust
for message in messages {
    match message.payload {
        Payload::Json(value) =>  {
            // Process JSON payload
        }
        _ => {
            warn!("Unsupported payload format: {}", messages_metadata.schema);
        }
    }
}

While the schema of messages (that will be consumed from the Iggy stream), cannot be controlled by the sink connector itself, the built-in configuration allows to decide what's the expected format of the messages (the particular StreamDecoder will be used).

messages_metadata.schema names the variant each Payload in the batch actually holds, which is not always the stream's schema setting. A decoder may return a different form than the wire format it reads: with schema = "avro" the Avro decoder extracts to JSON by default, so the batch arrives as Payload::Json. A configured transform can change the variant again. Match on the Payload itself and treat messages_metadata.schema as a description of what arrived, not of how the stream was configured.

One poll can reach the plugin as more than one consume() call. The runtime groups a batch into contiguous runs of one payload variant and sends each run on its own, so a batch whose variant changes partway through arrives as several calls.

Every call in that poll repeats the same messages_metadata.current_offset, which is the partition's high-water offset from the poll and not the offset of the last message in the call. A sink that keys a commit, a file name or a table version on current_offset has to tolerate the repeat.

A batch whose messages were all dropped by the decoder or by a transform still reaches the plugin as one empty call. Nothing is left to read a variant from, so that call carries the stream's configured schema instead: an avro stream tags its empty batches avro and its non-empty ones json.

Keep in mind, that it might be sometimes difficult/impossible e.g. to transform one format to another e.g. JSON to SBE or so, and in such a case, the consumed messages will be ignored.

Eventually, compile the source code and create a separate connector configuration file in the connectors directory (as specified in the main runtime config.toml). Make sure that path points to the existing plugin.

And that's all, enjoy using the sink connector!

On a side note, if you'd like to produce the messages to the Iggy stream instead, you can implement your own Source connector too :)


Overview (`core/connectors/sources/README.md`)

Apache Iggy Connectors - Source

Overview

Source connectors are responsible for ingesting data from external sources into Apache Iggy. They provide a way to integrate Apache Iggy with various data sources, such as databases, message queues, or file systems.

Available Sources

Source Description
elasticsearch_source Polls documents from Elasticsearch indices with timestamp-based tracking
http_source Webhook gateway: an embedded HTTP server shared by every instance, with per-endpoint bearer/HMAC auth and a management API for endpoints registered at runtime
influxdb_source Polls InfluxDB with cursor-based timestamp tracking; supports V2 (Flux, annotated CSV) and V3 (SQL, JSONL)
mqtt_source Subscribes to MQTT 3.1.1 or MQTT 5 brokers with QoS 0, 1, or 2
postgres_source Reads rows from PostgreSQL tables with multiple strategies: delete after read, mark as processed, or timestamp tracking
random_source Generates random test messages (useful for testing and development)

The source is represented by the single Source trait, which defines the basic interface for all source connectors. It provides methods for initializing the source, reading data from it, and closing the source.

rust
#[async_trait]
pub trait Source: Send + Sync {
    /// Invoked when the source is initialized, allowing it to perform any necessary setup.
    async fn open(&mut self) -> Result<(), Error>;

    /// Invoked every time a batch of messages is produced to the configured stream and topic.
    async fn poll(&self) -> Result<ProducedMessages, Error>;

    /// Invoked after the runtime sends the batch and persists its candidate state.
    async fn on_batch_result(&self, result: SourceBatchResult) -> Result<(), Error>;

    /// Invoked when the source is closed, allowing it to perform any necessary cleanup.
    async fn close(&mut self) -> Result<(), Error>;
}

Configuration

Each source connector is configured in its own separate configuration file within the connectors directory specified in the main runtime config.

rust
pub struct SourceConfig {
    pub key: String,
    pub enabled: bool,
    pub version: u64,
    pub name: String,
    pub path: String,
    pub transforms: Option<TransformsConfig>,
    pub streams: Vec<StreamProducerConfig>,
    pub plugin_config_format: Option<ConfigFormat>,
    pub plugin_config: Option<serde_json::Value>,
    pub verbose: bool, // Log message processing at info level instead of debug (default: false)
    pub benchmark: bool, // Emit per-batch timing events on the `iggy_connectors::benchmark` target (default: false)
}

Main runtime config (config.toml):

toml
[connectors]
config_type = "local"
config_dir = "path/to/connectors"

Source connector config (connectors/random_source_v0.toml):

toml
# Type of connector (sink or source)
type = "source"
key = "random" # Unique source ID

# Required configuration for a source connector
enabled = true # Toggle source on/off
version = 0
name = "Random source" # Name of the source
path = "libiggy_connector_random_source" # Path to the source connector
plugin_config_format = "toml"
verbose = false # Log message processing at info level instead of debug
benchmark = false # Emit per-batch timing events on `iggy_connectors::benchmark` target

# Collection of the streams to which the produced messages are sent
[[streams]]
stream = "example_stream"
topic = "example_topic"
schema = "json"
batch_length = 100
linger_time = "5ms"

# Custom configuration for the source connector, deserialized to type T from `plugin_config` field
[plugin_config]
messages_count = 10

# Optional data transformation(s) to be applied before sending messages to the stream
[transforms.add_fields]
enabled = true

# Collection of the fields transforms to be applied before sending messages to the stream
[[transforms.add_fields.fields]]
key = "message"
value.static = "hello"

Environment Variable Overrides

Configuration properties can be overridden using environment variables. The pattern follows: IGGY_CONNECTORS_SOURCE_[KEY]_[PROPERTY]

For example, to override the enabled property for a source with ID random:

bash
IGGY_CONNECTORS_SOURCE_RANDOM_ENABLED=false

Sample implementation

Let's implement the example source connector, which will simply generate the N random messages depending on the count specified in the configuration.

Additionally, our source connector will have its own state, which can be used e.g. to track the overall progress or store some relevant information when producing the data from the actual external sources or tooling.

Keep in mind, that the produced messages will be sent further to the specified stream, however it's already the responsibility of the runtime to handle the delivery.

Also, when implementing the source connector, make sure to use the source_connector! macro to expose the FFI interface and allow the connector runtime to register the source with the runtime.

And finally, each source should have its own, custom configuration, which is passed along with the unique plugin ID and optional state via expected new() method.

Let's start by defining the internal state and the public source connector along with its own configuration.

rust
#[derive(Debug)]
struct State {
    current_id: usize,
}
rust
#[derive(Debug)]
pub struct RandomSource {
    id: u32,
    messages_count: u32,
    state: Mutex<State>,
    pending_state: Mutex<Option<State>>,
}
rust
#[derive(Debug, Serialize, Deserialize)]
pub struct RandomSourceConfig {
    messages_count: Option<u32>,
}

At this point, we can expose the required new() method, which will be used by the runtime to create a new instance of the source connector. The id is assigned by the runtime, and represents the unique identifier of the source connector. The state is an optional connector state (e.g. persisted in the local file), which will be provided by the runtime, given that the connector has persisted its own state before the runtime was restarted.

rust
impl RandomSource {
    pub fn new(id: u32, config: RandomSourceConfig, state: Option<ConnectorState>) -> Self {
        let current_id = if let Some(state) = state {
            u64::from_le_bytes(
                state.0[0..8]
                    .try_into()
                    .inspect_err(|error| {
                        error!("Failed to convert state to current ID. {error}");
                    })
                    .unwrap_or_default(),
            )
        } else {
            0
        } as usize;

        RandomSource {
            id,
            messages_count: config.messages_count.unwrap_or(10),
            state: Mutex::new(State { current_id }),
            pending_state: Mutex::new(None),
        }
    }
}

We can invoke the expected macro to expose the FFI interface and allow the connector runtime to register the source within the runtime.

rust
source_connector!(RandomSource);

At a bare minimum, we need to add the following dependencies to the Cargo.toml file to compile the plugin at all:

  • dashmap
  • tracing

Before we make use of the Source trait, let's define the internal payload of the message that will be produced (e.g. as if it was pulled from some external database or so).

rust
#[derive(Debug, Serialize, Deserialize)]
struct Record {
    id: u64,
    text: String,
}

Now, let's implement the Source trait for our RandomSource struct. We'll assume that the amount of messages (provided in the config), will be generated every 100ms to mimic the behavior of a real-world external source. On top of this, we'll also keep track of the current ID of the last message produced and return the state along with the ProducedMessages - the state in this case, will be just a binary encoded number, but it can be anything else, including the complex structs.

text
/* Detailed source-code truncated for AI context efficiency. */

The source keeps its committed cursor unchanged while the batch is in flight. An ACK commits the staged cursor; a NACK discards it so the same range can be polled again. Sources that delete or mark upstream rows should stage those operations in the same way.

As you can see, the ProducedMessage can be customized to fit your needs, as all the fields will be directly mapped to the existing Iggy message struct.

It's also important to note, that the supported format(s) might vary depending on the connector implementation. For example, you might use JSON as the payload format, which can be then easily parsed and processed by downstream components such as data transforms, but at the same time, you could support the other formats and let the user decide which one to use.

While the final schema of messages (that will be appended to the Iggy stream), can be controlled with the built-in configuration (the particular StreamEncoder will be used), keep in mind, that it might be sometimes difficult/impossible e.g. to transform one format to another e.g. JSON to SBE or so, and in such a case, the produced messages will be ignored.

Eventually, compile the source code and create a separate connector configuration file in the connectors directory (as specified in the main runtime config.toml). Make sure that path points to the existing plugin.

And before starting the runtime, do not forget to create the specified stream and topic e.g. via Iggy CLI.

bash
iggy --username iggy --password iggy stream create example_stream

iggy --username iggy --password iggy topic create example_stream example_topic 1 none 1d --durability persisted

And that's all, enjoy using the source connector!

On a side note, if you'd like to process the messages consumed from the Iggy stream instead, you can implement your own Sink connector too :)


Overview (`foreign/csharp/Iggy_SDK.Tests.BDD/README.md`)

csharp bdd test

Scenario are located at /bdd/scenarios

env var

the bdd test suite has no defaults and fails when any of these is missing:

  • IGGY_TCP_ADDRESS="host:port" - server address
  • IGGY_ROOT_USERNAME / IGGY_ROOT_PASSWORD - root credentials
  • IGGY_TCP_ADDRESS_LEADER / IGGY_TCP_ADDRESS_FOLLOWER - cluster addresses, leader redirection scenarios only

from the repository root run

bash
./scripts/run-bdd-tests.sh csharp

the script starts the server, brings up the leader and follower for the cluster
scenarios, and sets every variable above, so none of them have to be exported by
hand. see /bdd/README.md for the sdk and feature
matrix.

Run locally

for iterating against a server you started yourself. note: bdd test expect an
iggy-server started with the same root credentials

from /foreign/csharp/Iggy_SDK.Tests.BDD run

bash
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
  dotnet test

Troubleshooting

Sometimes tests might be run twice or have errors during build.
It's because link to .feature files and problem with generated code.
To fix it, run dotnet clean


Overview (`helm/charts/iggy/README.md`)

iggy

A Helm chart for Apache Iggy server and web-ui

Prerequisites

  • Kubernetes 1.19+
  • Helm 3.2.0+
  • PV provisioner support in the underlying infrastructure (if persistence is enabled)
  • Prometheus Operator CRDs if server.serviceMonitor.enabled=true

io_uring Requirements

Iggy server uses io_uring for high-performance async I/O. This requires:

  1. Linux kernel 5.19 or newer on the node

    • Shard rings require IORING_SETUP_COOP_TASKRUN and IORING_SETUP_TASKRUN_FLAG.
    • Compio's asynchronous socket creation requires IORING_OP_SOCKET.
    • Mainline Linux provides these features starting in 5.19.
    • Older kernels fail during shard startup. The node kernel matters, not the container image.
  2. IPC_LOCK capability - For locking memory required by io_uring

  3. Unconfined seccomp profile - To allow io_uring syscalls

These are configured by default for the Iggy server via the chart's root-level
securityContext and podSecurityContext. The web UI uses ui.securityContext
and ui.podSecurityContext, which default to empty.

Some local or container-based Kubernetes environments may still fail during Iggy runtime
initialization if the node/kernel does not provide the io_uring support required by the
server runtime.

Quick Start

bash
# Clone the repository
git clone https://github.com/apache/iggy.git
cd iggy

# Install with persistence enabled
helm install iggy ./helm/charts/iggy \
  --set server.persistence.enabled=true

# Install with custom root credentials
helm install iggy ./helm/charts/iggy \
  --set server.persistence.enabled=true \
  --set server.users.root.username=admin \
  --set server.users.root.password=secretpassword

Note: server.serviceMonitor.enabled defaults to false.
Enable it only if Prometheus Operator is installed and you want a ServiceMonitor resource.
The server still requires node/kernel support for io_uring, including on clean local clusters such as kind or minikube.

Installation

From Git Repository

bash
git clone https://github.com/apache/iggy.git
cd iggy
helm install iggy ./helm/charts/iggy

With Persistence

bash
helm install iggy ./helm/charts/iggy \
  --set server.persistence.enabled=true \
  --set server.persistence.size=50Gi

With Custom Values File

bash
helm install iggy ./helm/charts/iggy -f custom-values.yaml

If Prometheus Operator is installed and you want monitoring, set
server.serviceMonitor.enabled=true in custom-values.yaml or pass it on the
command line with --set server.serviceMonitor.enabled=true.

Cluster Mode

The server runs a Viewstamped Replication cluster when cluster.enabled is set
and each node is told which roster entry it is. The chart models this as one
release per node
: every release is handed the same roster and overrides only
its own identity.

helm/charts/iggy/examples/cluster-3-node.yaml is a ready three-node roster.
Point the ip values at the nodes you will pin the releases to, then:

bash
for i in 0 1 2; do
  helm upgrade --install "iggy-n$i" ./helm/charts/iggy \
    -f ./helm/charts/iggy/examples/cluster-3-node.yaml \
    --set "server.cluster.selfReplicaId=$i" \
    --set "server.nodeSelector.kubernetes\.io/hostname=node-$i"
done

The chart turns server.cluster into the server's IGGY_CLUSTER_* environment
variables and passes --replica-id, so no configuration file is mounted. The
values mirror the server's [[cluster.nodes]] config one for one.

Why hostNetwork and node pinning are required

The server's consensus listener binds cluster.nodes[*].ip verbatim, unlike
the client listeners, which keep their own bind address and take only the port
from the roster. A pod therefore has to own the address its roster entry names.
Pod IPs are neither stable nor known before install, and a Service ClusterIP is
not an address a pod can bind, so the workable layout today is hostNetwork: true with each release pinned to a node and its roster ip set to that node's
IP. Node IPs are known up front and survive a pod restart, which is what lets a
replica come back and rejoin.

The cost is real: the server pod shares the node's network namespace and its
ports. Weigh it before running this in a shared cluster.

If a release lands on a node whose IP is not the one in its roster entry, the
server refuses to start rather than misbehaving quietly:

text
Error: ShardJoinFailures { failures: [ShardJoinFailure { shard_id: 0,
  kind: Error(Iggy(CannotBindToSocket("10.0.1.11:9090"))) }] }

Check the nodeSelector on that release against the ip in its roster entry.

Upgrades

Host ports make a rolling update impossible: the replacement pod cannot bind
ports the outgoing pod still holds, so it stays Pending while the Deployment
waits for it to become ready. The chart therefore switches the server to the
Recreate strategy whenever hostNetwork is set, which takes the node down
for the length of the restart. Roll one release at a time and let the
cluster regain quorum before starting the next.

Turning hostNetwork on for a release that already exists moves the Deployment
from RollingUpdate to Recreate. Under Helm 4's server-side apply that is
rejected, because the patch cannot drop the rollingUpdate block the API server
defaulted in:

text
Deployment.apps "iggy-n0" is invalid: spec.strategy.rollingUpdate: Forbidden:
  may not be specified when strategy `type` is 'Recreate'

On Helm 4, run that one upgrade with helm upgrade --server-side=false, which
replaces the strategy in place. Helm 3 has no such flag, since it does not use
server-side apply. Set server.strategy explicitly if you want a different
strategy.

Roster rules

  • Every node runs the identical server.cluster.nodes list. Only
    selfReplicaId differs between releases.
  • replicaId values are unique and cover 0..N-1 for an N-node roster.
  • ports.tcpReplica is required on every entry. In cluster mode the server
    takes every listener port from the roster and will not fall back to defaults,
    because two nodes on one host would otherwise race for the same socket. The
    remaining ports default to server.ports.
  • server.cluster.name is hashed into the on-disk cluster id on first boot.
    Changing it later makes the server refuse to start against existing data.
  • ip must be a literal IP. Hostnames are rejected. Use
    advertisedAddress for the name clients dial, which does accept DNS.

Secrets

Use one Secret for initial root credentials and the shared cluster keys. Set IGGY_ROOT_PASSWORD to the chosen root password. The chart requires explicit root credentials because its pinned image predates journaled root initialization. Create the Secret in the release namespace before the first install:

bash
JWT="$(head -c 32 /dev/urandom | base64)"
kubectl create secret generic iggy-cluster-secrets \
  --from-literal=username=iggy \
  --from-literal=password="${IGGY_ROOT_PASSWORD:?set IGGY_ROOT_PASSWORD}" \
  --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \
  --from-literal=jwtEncodingSecret="$JWT" \
  --from-literal=jwtDecodingSecret="$JWT" \
  --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)"

examples/cluster-3-node.yaml points server.users.root, server.encryption,
server.jwt and server.cluster.auth at that one Secret. All four also accept
an inline value, which the chart turns into a Secret of its own:
<release>-root-credentials for the root username and password,
<release>-secrets for the other three. That is convenient for a single node
and a poor fit for a cluster, since the value then lives in every release's
stored values.

  • server.users.root must use the same Secret on every node. The pinned image creates root independently on each replica from its local environment. Different Secrets create different root accounts across the cluster. Newer images with journaled root initialization replicate the first leader's account and recover it on restart. Supply the same initial credentials to every replica so any node can initialize root after a promotion.
  • server.cluster.auth makes every replica connection complete an
    authenticated handshake or be rejected. The key is at least 32 bytes of
    CSPRNG output. Turning it on or off is a coordinated restart of the whole
    cluster: a node that authenticates cannot talk to one that does not.
  • server.encryption encrypts message payloads and state commands at rest
    with AES-256-GCM, under a 32-byte base64 key. A node holding a different key
    cannot read what its peers wrote. The chart selects the legacy configuration
    for versioned images through 0.9.0-edge.7, including its pinned image.
    For newer builds with [encryption], set server.encryption.configVersion=flat.
    Custom tags require an explicit flat or legacy layout; legacy uses
    [system.encryption].
  • server.jwt makes HTTP bearer tokens valid on every node and survive a
    restart. With cluster.auth enabled the server already derives a cluster-wide
    key from the replica PSK, so setting the JWT secrets is an alternative to that
    rather than an addition; setting them anyway keeps tokens working if auth is
    later turned off.

None of the encryption, JWT and replica-auth values is written to the data
directory, and each is masked as ****** in the startup log.

Storage

A replica cannot move between nodes without invalidating its roster entry, so
give each release storage that stays on its node, and size
server.persistence per node rather than for the cluster. A replica that starts
on an empty volume rejoins by state transfer, which is correct but re-reads the
whole dataset from its peers. server.persistence.enabled: false puts the
replica on emptyDir and forces exactly that on every pod replacement, so the
cluster example enables persistence and leaves storageClass for you to point
at a node-local provisioner.

What the chart refuses

server.replicaCount > 1 fails at render time. Scaling the server Deployment
produces N independent servers behind one Service, all writing the same PVC
subpath with no lock between them, which corrupts the data directory while
helm --wait still reports success. The chart ships no HorizontalPodAutoscaler
for the same reason. Cluster size is a roster decision, so add a node to
server.cluster.nodes and install another release instead.

Uninstallation

bash
helm uninstall iggy

Testing

The chart CI paths are also available locally from the repository root.

Render Validation

If helm is already installed locally:

bash
scripts/ci/test-helm.sh validate

If you want the pinned Linux CI tool version instead:

bash
scripts/ci/setup-helm-tools.sh
scripts/ci/test-helm.sh validate

This runs helm lint --strict plus the CI render scenarios, including:

  • default chart output
  • all-features render
  • legacy Kubernetes 1.18 API coverage
  • server-only render
  • UI-only render
  • existing-secret render

Runtime Smoke Test

The smoke path requires helm, kind, kubectl, and curl.

Before running the local smoke path, keep these common gotchas in mind:

  • the Iggy server requires working io_uring support from the Kubernetes node/kernel/runtime
  • the server also needs enough available memory and locked-memory headroom during startup
  • scripts/ci/test-helm.sh cleanup-smoke removes the Helm release and smoke namespace, but it does not delete the reusable kind cluster created by scripts/ci/setup-helm-smoke-cluster.sh

If helm and kind are already installed:

bash
scripts/ci/setup-helm-smoke-cluster.sh
scripts/ci/test-helm.sh smoke --cleanup

If you want the pinned Linux CI tool versions:

bash
scripts/ci/setup-helm-tools.sh --install-kind
scripts/ci/setup-helm-smoke-cluster.sh
scripts/ci/test-helm.sh smoke --cleanup

If a previous local smoke install failed and left resources behind, reset the smoke namespace with:

bash
scripts/ci/test-helm.sh cleanup-smoke

On Apple Silicon hosts, the released arm64 server image may still fail during the runtime smoke path in kind. If your Docker setup supports amd64 emulation well enough, you can try recreating the dedicated smoke cluster with:

bash
HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh

The smoke script defaults IGGY_SHARDING_CPU_ALLOCATION=1 for the server pod so the local kind path avoids the chart's numa:auto default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set HELM_SMOKE_SERVER_CPU_ALLOCATION before running scripts/ci/test-helm.sh smoke. Pass --cleanup to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources.

On smoke-test failures you can collect the same diagnostics as CI with:

bash
scripts/ci/test-helm.sh collect-smoke-diagnostics

Note: scripts/ci/setup-helm-tools.sh currently supports Linux x86_64 only.
On other local platforms, install equivalent helm and kind binaries yourself and then use the same scripts above.
The runtime smoke test may still fail on some local/containerized clusters if the node/kernel does not provide the io_uring support required by the server runtime even after the local sharding override, or if the local environment does not provide enough memory for the server to initialize cleanly.

Troubleshooting

Pod CrashLoopBackOff with "Out of memory" error

If you see:

text
Cannot create runtime: Out of memory (os error 12)

This means io_uring cannot lock sufficient memory. Ensure:

  1. securityContext.capabilities.add includes IPC_LOCK
  2. podSecurityContext.seccompProfile.type is Unconfined

These server settings are set by default but may be overridden.

Pod CrashLoopBackOff with "Invalid argument" during server startup

If the Iggy server exits with a panic similar to:

text
called `Result::unwrap()` on an `Err` value: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }

the Kubernetes node may not support the io_uring runtime configuration required by the server.
This has been observed on local/container-based clusters even when IPC_LOCK and
podSecurityContext.seccompProfile.type=Unconfined are set.

ServiceMonitor CRD not found

If you see:

text
no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"

Either install Prometheus Operator or disable ServiceMonitor:

bash
helm install iggy ./helm/charts/iggy --set server.serviceMonitor.enabled=false

Server not accessible from other pods

Ensure the server binds to 0.0.0.0 instead of 127.0.0.1. This is configured by default via environment variables:

  • IGGY_HTTP_ADDRESS=0.0.0.0:3000
  • IGGY_TCP_ADDRESS=0.0.0.0:8090
  • IGGY_QUIC_ADDRESS=0.0.0.0:8080

A wildcard bind says which interfaces accept connections, not where clients
reach the pod, so the server also needs the address to publish in cluster
metadata. Server builds that carry the setting refuse to start without it.
0.9.0-edge.6, the image this chart pins, predates it:
that build logs IGGY_NODE_ADVERTISED_ADDRESS as an unknown variable and
publishes the bind address, so the chart's default stays inert until the pinned
image moves past it. Either way the chart sets IGGY_NODE_ADVERTISED_ADDRESS to
the in-cluster Service DNS name; override it with server.advertisedAddress
when clients arrive through a LoadBalancer or an Ingress.

In cluster mode the server ignores the variable altogether and publishes each
node's roster ip, or its advertisedAddress when the entry carries one, so
the chart leaves the variable out there and refuses a render that sets
server.advertisedAddress alongside server.cluster.enabled.

Declaring IGGY_NODE_ADVERTISED_ADDRESS in server.env yourself works too:
the chart then leaves its own default out, so the variable is declared once.
Give that entry a non-empty value; an empty one suppresses the chart default
and reaches the server as unset, so the render fails instead.
Setting it in server.env and in server.advertisedAddress at the same time
is refused at render time rather than resolved silently, since only the
server.env entry would take effect.

Accessing the Server

Port Forward

bash
# HTTP API
kubectl port-forward svc/iggy 3000:3000

# Web UI
kubectl port-forward svc/iggy-ui 3050:3050

Using Ingress

Enable ingress in values. Set className and any controller-specific annotations to match your
ingress implementation:

yaml
server:
  ingress:
    enabled: true
    className: "<your-ingress-class>"
    annotations: {}
    hosts:
      - host: iggy.example.com
        paths:
          - path: /
            pathType: Prefix
    tls: []

ui:
  ingress:
    enabled: true
    className: "<your-ingress-class>"
    annotations: {}
    hosts:
      - host: iggy-ui.example.com
        paths:
          - path: /
            pathType: Prefix
    tls: []

The chart is controller-neutral and works with any Ingress controller (nginx, Traefik, HAProxy, Contour, etc.).

Development

Formatting and Linting

This chart uses automated tools to maintain code quality:

Tool Purpose Files
helm-docs Auto-generate this README values.yaml → README.md
yamllint YAML formatting values.yaml, Chart.yaml
helmfmt Helm template formatting templates/*.yaml, *.tpl

Local Development

bash
# Install tools (macOS)
brew install norwoodj/tap/helm-docs
pip install "yamllint==1.38.0"
go install github.com/digitalstudium/helmfmt@latest

# Format templates
helmfmt helm/charts/iggy/

# Lint YAML files
yamllint -c helm/charts/iggy/.yamllint.yml helm/charts/iggy/

# Regenerate README after changing values.yaml
cd helm/charts/iggy && helm-docs

# Run all validations
scripts/ci/test-helm.sh validate

Pre-commit Hooks

These tools are integrated with pre-commit. Install hooks with:

bash
pre-commit install

Values

Key Type Default Description
additionalLabels object {} Additional labels for all resources
fullnameOverride string "" Override full release name
imagePullSecrets list [] Image pull secrets for private registries
nameOverride string "" Override chart name
podAnnotations object {} Pod annotations
podSecurityContext object {"seccompProfile":{"type":"Unconfined"}} Pod security context (server uses io_uring, requires unconfined seccomp)
resources object {} Resource limits and requests for server
securityContext object {"capabilities":{"add":["IPC_LOCK"]}} Container security context (server requires IPC_LOCK for io_uring)
server object {"advertisedAddress":"","affinity":{},"cluster":{"auth":{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""},"enabled":false,"name":"iggy-cluster","nodes":[],"requireHostNetwork":true,"selfReplicaId":0},"enabled":true,"encryption":{"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""},"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"extraArgs":[],"hostNetwork":false,"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":""},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"jwt":{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}},"nodeSelector":{},"persistence":{"accessMode":"ReadWriteOnce","annotations":{},"enabled":false,"existingClaim":"","size":"8Gi","storageClass":""},"ports":{"http":3000,"quic":8080,"tcp":8090,"websocket":8092},"replicaCount":1,"service":{"port":3000,"type":"ClusterIP"},"serviceMonitor":{"additionalLabels":{},"authorization":{},"enabled":false,"honorLabels":false,"interval":"30s","namespace":"","path":"/metrics","scrapeTimeout":"10s"},"strategy":{},"tolerations":[],"users":{"root":{"createSecret":true,"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"password":"changeit","username":"iggy"}}} Iggy server configuration
server.advertisedAddress string "" Client-facing address published in cluster metadata. Declaring IGGY_NODE_ADVERTISED_ADDRESS in server.env instead also works, but setting both is refused at render time. Empty falls back to the in-cluster Service DNS name. Ignored in cluster mode, where the address comes from the node's roster entry, so setting both is refused there too.
server.affinity object {} Affinity rules for server pods
server.cluster.auth object {"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""} Replica-to-replica authentication on the consensus port. When enabled every peer must complete an authenticated handshake or be rejected, and a shared secret becomes mandatory. Enabling it on a running cluster is a coordinated-restart change, not a rolling one.
server.cluster.auth.enabled bool false Require the authenticated replica handshake
server.cluster.auth.existingSecret.name string "" Name of an existing Secret holding the pre-shared keys
server.cluster.auth.existingSecret.previousSharedSecretKey string "clusterPreviousSharedSecret" Key inside that Secret holding the retiring shared secret
server.cluster.auth.existingSecret.sharedSecretKey string "clusterSharedSecret" Key inside that Secret holding the active shared secret
server.cluster.auth.previousSharedSecret string "" Retiring key, accepted for verification only while a rotation is in flight. Leave empty outside a rotation.
server.cluster.auth.sharedSecret string "" Cluster-wide pre-shared key, at least 32 bytes of CSPRNG output, byte-identical on every node. Ignored when existingSecret.name is set.
server.cluster.enabled bool false Enable cluster (VSR consensus) mode. One Helm release per node: every release shares the same nodes roster and overrides only selfReplicaId. See the Cluster Mode section of the chart README.
server.cluster.name string "iggy-cluster" Cluster name, byte-identical on every node. Hashed into the on-disk cluster id on first boot, so changing it later means starting from an empty data directory.
server.cluster.nodes list [] Cluster roster, mirroring the server's [[cluster.nodes]] config. Every node runs the identical list. ip is the replica-plane address: this node's consensus listener binds it verbatim and every peer dials it verbatim, so it must be a literal IP that the pod itself owns. With server.hostNetwork that is the node IP. ports.tcpReplica is required on every entry; the remaining ports default to server.ports.
server.cluster.requireHostNetwork bool true Refuse to render a cluster node without server.hostNetwork. The replica listener binds the roster ip verbatim, which no pod owns on the cluster network, so the pod would die at boot with CannotBindToSocket. Set this to false only when the roster ip is an address the pod itself holds.
server.cluster.selfReplicaId int 0 Which nodes entry this release runs, matched against replicaId.
server.enabled bool true Enable the Iggy server deployment
server.encryption object {"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""} Server-side encryption of message payloads and state commands, using AES-256-GCM. Every node of a cluster must hold the identical key, or it cannot read data another node wrote.
server.encryption.configVersion string "auto" Encryption config layout: auto selects legacy for released image versions through 0.9.0-edge.7. Set flat for newer server builds, or legacy for custom builds that still use [system.encryption].
server.encryption.enabled bool false Enable encryption at rest
server.encryption.existingSecret.key string "encryptionKey" Key inside that Secret
server.encryption.existingSecret.name string "" Name of an existing Secret holding the encryption key
server.encryption.key string "" 32-byte key, base64 encoded. Ignored when existingSecret.name is set. Prefer existingSecret outside development: a value here is stored in the Helm release and readable by anyone who can read it.
server.env list [{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}] Environment variables for the server container
server.extraArgs list [] Extra command-line arguments appended to the server entrypoint, e.g. ["--with-default-root-credentials"] for a throwaway development install. --replica-id is not one of them: the chart passes it already whenever cluster.enabled is set.
server.hostNetwork bool false Run the server pod in the host network namespace. Required for cluster mode, where the replica listener binds the roster IP verbatim.
server.image.pullPolicy string "Always" Image pull policy
server.image.repository string "apache/iggy" Server image repository
server.image.tag string "" Server image tag. Empty uses the chart appVersion.
server.ingress.annotations object {} Ingress annotations (controller-specific)
server.ingress.className string "" Ingress class name (controller-neutral)
server.ingress.enabled bool false Enable ingress for the server
server.ingress.hosts list [{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}] Ingress hosts configuration
server.ingress.tls list [] Ingress TLS configuration
server.jwt object {"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}} Secrets used to sign and validate HTTP bearer tokens. Left unset, each node generates a random secret on every start, which invalidates tokens across restarts and keeps them node-local. Setting the identical secret on every node makes bearers valid cluster-wide and activates follower to primary HTTP forwarding; cluster.auth.enabled derives the same thing from the replica PSK, so it is an alternative rather than an addition.
server.jwt.decodingSecret string "" Decoding secret. Ignored when existingSecret.name is set.
server.jwt.encodingSecret string "" Encoding secret. Ignored when existingSecret.name is set.
server.jwt.existingSecret.decodingSecretKey string "jwtDecodingSecret" Key inside that Secret holding the decoding secret
server.jwt.existingSecret.encodingSecretKey string "jwtEncodingSecret" Key inside that Secret holding the encoding secret
server.jwt.existingSecret.name string "" Name of an existing Secret holding the JWT secrets
server.nodeSelector object {} Node selector for server pods
server.persistence.accessMode string "ReadWriteOnce" PVC access mode
server.persistence.annotations object {} PVC annotations
server.persistence.enabled bool false Enable persistence using PVC
server.persistence.existingClaim string "" Use existing PVC (requires persistence.enabled: true)
server.persistence.size string "8Gi" PVC storage size
server.persistence.storageClass string "" Storage class for PVC (empty uses default provisioner)
server.ports.http int 3000 HTTP API port
server.ports.quic int 8080 QUIC protocol port (UDP)
server.ports.tcp int 8090 TCP protocol port
server.ports.websocket int 8092 WebSocket protocol port
server.replicaCount int 1 Number of server replicas
server.service.port int 3000 Service port for the server
server.service.type string "ClusterIP" Service type for the server
server.serviceMonitor.additionalLabels object {} Additional labels for the ServiceMonitor
server.serviceMonitor.authorization object {} Authorization for the scrape request. The metrics endpoint requires a bearer credential (e.g. a personal access token stored in a Secret): authorization: credentials: name: iggy-metrics-token key: token
server.serviceMonitor.enabled bool false Enable ServiceMonitor for Prometheus Operator
server.serviceMonitor.honorLabels bool false Honor labels from the target
server.serviceMonitor.interval string "30s" Scrape interval (fallback to Prometheus default)
server.serviceMonitor.namespace string "" Namespace to deploy the ServiceMonitor
server.serviceMonitor.path string "/metrics" Path to scrape metrics from
server.serviceMonitor.scrapeTimeout string "10s" Timeout for scrape metrics request
server.strategy object {} Deployment update strategy. Empty lets the chart choose: Recreate when hostNetwork is set, because a rolling update would wait forever for a replacement pod that cannot bind host ports the outgoing pod still holds, and the Kubernetes default otherwise.
server.tolerations list [] Tolerations for server pods
server.users.root.createSecret bool true Create a secret for the root user credentials
server.users.root.existingSecret.name string "" Name of existing secret for root credentials
server.users.root.existingSecret.passwordKey string "password" Key in secret for password
server.users.root.existingSecret.usernameKey string "username" Key in secret for username
server.users.root.password string "changeit" Root password
server.users.root.username string "iggy" Root username
serviceAccount.annotations object {} Service account annotations
serviceAccount.create bool true Create a service account
serviceAccount.name string "" Service account name (generated if not set)
ui object {"affinity":{},"enabled":true,"env":{},"image":{"pullPolicy":"Always","repository":"apache/iggy-web-ui","tag":"edge"},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"nodeSelector":{},"podSecurityContext":{},"ports":{"http":3050},"replicaCount":1,"resources":{},"securityContext":{},"server":{"endpoint":""},"service":{"port":3050,"type":"ClusterIP"},"tolerations":[]} Iggy web UI configuration
ui.affinity object {} Affinity rules for UI pods
ui.enabled bool true Enable the web UI deployment
ui.env object {} Extra environment variables for UI container
ui.image.pullPolicy string "Always" UI image pull policy
ui.image.repository string "apache/iggy-web-ui" UI image repository
ui.image.tag string "edge" UI image tag (overrides chart appVersion)
ui.ingress.annotations object {} Ingress annotations (controller-specific)
ui.ingress.className string "" Ingress class name (controller-neutral)
ui.ingress.enabled bool false Enable ingress for the UI
ui.ingress.hosts list [{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}] Ingress hosts configuration
ui.ingress.tls list [] Ingress TLS configuration
ui.nodeSelector object {} Node selector for UI pods
ui.podSecurityContext object {} Pod security context for UI pods
ui.ports.http int 3050 HTTP port for web UI
ui.replicaCount int 1 Number of UI replicas
ui.resources object {} Resource limits and requests for UI
ui.securityContext object {} Container security context for UI
ui.server.endpoint string "" Iggy server endpoint (blank uses service URL)
ui.service.port int 3050 Service port for the UI
ui.service.type string "ClusterIP" Service type for the UI
ui.tolerations list [] Tolerations for UI pods

Autogenerated from chart metadata using helm-docs v1.14.2


Overview (`core/connectors/sinks/bigquery_sink/README.md`)

BigQuery Sink Connector

The BigQuery sink connector consumes messages from Iggy topics and writes them into a Google BigQuery table through the Storage Write API _default stream, with rows encoded as Arrow record batches.

Features

  • Storage Write API: gRPC AppendRows on the table's _default stream. No legacy insertAll.
  • Two modes: mapped maps top-level JSON fields to table columns by name. raw stores the whole payload in one JSON, STRING or BYTES column.
  • Schema validation at startup: the table schema is read with tables.get and checked before any data flows. Unsupported column types fail open().
  • Iggy metadata columns: stream, topic, partition, offset, timestamp and message id, plus optional headers, for lineage and deduplication.
  • Per-row error handling: a row that cannot be written is dropped and logged with its offset. The rest of the batch is still written.
  • Request sizing: batches are split below the 10 MB AppendRows limit.
  • Retries: transient gRPC failures are retried with jittered exponential backoff.

Configuration

toml
type = "sink"
key = "bigquery"
enabled = true
version = 0
name = "BigQuery sink"
path = "target/release/libiggy_connector_bigquery_sink"

[[streams]]
stream = "example_stream"
topics = ["example_topic"]
schema = "json"
batch_length = 1000
poll_interval = "5ms"
consumer_group = "bigquery_sink_connector"

[plugin_config]
project_id = "my-project"
dataset = "iggy"
table = "events"
mode = "mapped"
credentials_path = "/secrets/bigquery-writer.json"
include_metadata = true

Configuration Options

Option Type Default Description
project_id string required Google Cloud project that owns the dataset
dataset string required BigQuery dataset
table string required Target table. It must already exist
mode string "mapped" mapped or raw
payload_column string "payload" Payload column in raw mode
credentials_path string none Service account key file
credentials_json string none Inline service account key. Mutually exclusive with credentials_path
include_metadata bool true Write the iggy_* metadata columns listed below
include_headers bool false Write message headers into iggy_headers
missing_value string "default" How BigQuery fills columns omitted from a row group: default or null
max_request_bytes usize 8388608 Upper bound for one AppendRows request, clamped to 64 KiB..9 MiB
max_retries u32 3 Total attempts per call, including the first
retry_delay string "1s" Base retry delay
max_retry_delay string "30s" Upper bound for one retry delay
timeout string "30s" Overall startup timeout and deadline for each tables.get, write-stream setup and AppendRows operation
verbose_logging bool false Log each batch at info level instead of debug
emulator_endpoint string none REST endpoint override for local fakes and emulators. Must be set with emulator_grpc_endpoint; no credentials are used
emulator_grpc_endpoint string none Storage Write API emulator host:port override

Authentication

The connector picks the first source that applies:

  1. emulator_endpoint and emulator_grpc_endpoint: no credentials, plain-text connections. For tests only. Combining emulator endpoint overrides with either credential option is rejected.
  2. credentials_path: a service account key file.
  3. credentials_json: an inline service account key.
  4. Application Default Credentials: GOOGLE_APPLICATION_CREDENTIALS, gcloud auth application-default login, or the metadata server on GCE, GKE and Cloud Run.
toml
# Key file
credentials_path = "/secrets/bigquery-writer.json"

# Inline key, for example injected through an environment override:
# IGGY_CONNECTORS_SINK_BIGQUERY_PLUGIN_CONFIG_CREDENTIALS_JSON='{"type":"service_account",...}'

IAM permissions

The identity needs bigquery.tables.get (read the schema) and bigquery.tables.updateData (append rows) on the target table. The predefined role roles/bigquery.dataEditor, granted on the table or its dataset, includes both.

Modes

`mapped` (default)

Each payload must be a JSON object. Top-level fields map to table columns by name. Fields that match no column are ignored.

  • Payload types: Json, Text and Proto text are accepted when they hold a JSON object. Raw, FlatBuffer and Avro payloads are rejected.
  • Missing columns and defaults: rows are grouped by which defaulted columns they set. Each writer schema omits the defaulted columns absent from that group, so BigQuery applies defaults per row. An explicit null for a REQUIRED column with a default is treated as omitted. A REQUIRED column without a default value must be present and non-null in every row.
  • REPEATED columns that are absent or null are written as empty arrays.

`raw`

The payload is stored in payload_column. Every other table column is left to its default, so a REQUIRED column without a default fails open().

Payload column type Accepted payloads
JSON Json, plus Text, Proto and UTF-8 Raw payloads that parse as JSON
STRING Json (serialized), Text, Proto, UTF-8 Raw
BYTES every payload type, as its raw bytes

Metadata Columns

With include_metadata = true the table must contain these columns. A missing column fails open() with the DDL to add it.

Column BigQuery type Value
iggy_stream STRING Stream name
iggy_topic STRING Topic name
iggy_partition_id INT64 Partition id
iggy_offset INT64 Message offset
iggy_timestamp TIMESTAMP Message timestamp
iggy_id STRING Message id, as a decimal string (it is a u128)
iggy_headers JSON or STRING Headers, only with include_headers = true. Binary values are written as {"data": "", "iggy_header_encoding": "base64"}

Type Mapping

BigQuery Accepted JSON values
STRING, GEOGRAPHY (WKT) string
BYTES base64 string
INT64 integer, or a string holding one
FLOAT64 number, or a string holding one
BOOL true / false
NUMERIC, BIGNUMERIC number or string, using the table column's declared precision and scale. Values that require rounding or exceed the precision are rejected per row. Prefer strings to avoid float rounding
TIMESTAMP RFC 3339 string, or an integer in microseconds since the epoch
DATETIME YYYY-MM-DDTHH:MM:SS[.ffffff] string
DATE YYYY-MM-DD string
TIME HH:MM:SS[.ffffff] string
JSON any JSON value, stored as that value
RECORD object
REPEATED array

INTERVAL and RANGE columns are not supported and fail open().

Reliability

Errors and retries

  • UNAVAILABLE, UNKNOWN, CANCELLED, DEADLINE_EXCEEDED, INTERNAL, ABORTED, RESOURCE_EXHAUSTED and UNAUTHENTICATED are retried up to max_retries attempts in total. This covers connection resets and transient token refresh failures; the configured timeout keeps permanently invalid credentials bounded.
  • Every other gRPC error, including INVALID_ARGUMENT and PERMISSION_DENIED, fails the request without a retry.
  • tables.get retries HTTP 429, 5xx and transport errors. A 403 or 404 fails open() immediately.

Bad rows

A message that cannot become a row (wrong payload type, a value that does not fit its column, a missing REQUIRED value, a row larger than max_request_bytes) is dropped before the request is sent. When BigQuery reports row-level errors, it appends nothing from that request: the connector drops the reported rows and appends the remaining rows once more. If that second append also reports row errors, the remaining rows of that request are dropped and the batch returns an error.

Every dropped row is logged at warn with its stream, topic, partition and offset. If every row is rejected, consume() returns an error so the runtime does not count the batch as processed. There is no dead-letter queue. On shutdown the connector logs rows written, rejected, failed and unconfirmed.

Delivery semantics

The connector runtime commits consumer group offsets when messages are polled, before consume() runs. If consume() returns an error, that batch is not redelivered and the affected consumer task stops until an operator restarts the connector. As a result:

  • Rows in a request that hits a permanent error are lost. Requests split from the same polled batch are attempted independently, so earlier or later chunks may still be written.
  • When the retry budget is exhausted after an ambiguous transport failure, the connector cannot know whether that request was written. Its rows may be absent or duplicated and are counted as unconfirmed. The runtime increments iggy_connector_errors_total, marks the connector as errored and stops the affected consumer task.
  • A process crash between the offset commit and a successful append also loses that batch.
  • The _default stream has no append offsets. When an append result is lost and the request is retried, rows can be written twice. Deduplicate downstream on iggy_stream, iggy_topic, iggy_partition_id and iggy_offset, for example:
sql
SELECT * EXCEPT(rn) FROM (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY iggy_stream, iggy_topic, iggy_partition_id, iggy_offset, iggy_timestamp
    ORDER BY iggy_timestamp
  ) AS rn
  FROM `my-project.iggy.events`
) WHERE rn = 1

Schema changes

The schema is read once in open(). Restart the connector after changing the table.

Testing

Unit tests cover configuration, credential source selection, schema mapping, Arrow encoding, request splitting and error classification.

tests/bigquery_sink.rs runs the sink against an in-process fake (tests/common/mod.rs): an axum tables.get endpoint and a tonic BigQueryWrite service that records every AppendRows request as an Arrow batch and answers from a script. It covers successful writes, request splitting, local and BigQuery-reported row errors, retryable and permanent failures, and open() failures. No Google Cloud access is needed.

The fake cannot check what only BigQuery knows: that it accepts the Arrow encoding, its type coercion, and real credentials. Verify those manually as follows.

Manual verification against BigQuery

  1. Create a dataset and table (requires gcloud and bq):

    bash
    export PROJECT=my-project
    gcloud auth application-default login
    gcloud services enable bigquery.googleapis.com bigquerystorage.googleapis.com --project "$PROJECT"
    bq --project_id="$PROJECT" mk --dataset iggy
    bq --project_id="$PROJECT" query --use_legacy_sql=false "
    CREATE TABLE iggy.events (
      user_id INT64 NOT NULL,
      event STRING,
      amount NUMERIC,
      created TIMESTAMP DEFAULT CURRENT_TIMESTAMP(),
      attrs JSON,
      iggy_stream STRING,
      iggy_topic STRING,
      iggy_partition_id INT64,
      iggy_offset INT64,
      iggy_timestamp TIMESTAMP,
      iggy_id STRING
    )"
  2. Build the binaries and the plugin, and write a runtime config that loads only this connector:

    bash
    cargo build --bin iggy-server --bin iggy --bin iggy-connectors
    cargo build -p iggy_connector_bigquery_sink
    mkdir -p /tmp/bq-sink-test/connectors
    sed 's#^config_dir = .*#config_dir = "/tmp/bq-sink-test/connectors"#' \
      core/connectors/runtime/example_config/config.toml > /tmp/bq-sink-test/config.toml
    cat > /tmp/bq-sink-test/connectors/bigquery.toml <<EOF
    type = "sink"
    key = "bigquery"
    enabled = true
    version = 0
    name = "BigQuery sink"
    path = "$PWD/target/debug/libiggy_connector_bigquery_sink"
    
    [[streams]]
    stream = "demo_stream"
    topics = ["demo_topic"]
    schema = "json"
    batch_length = 100
    poll_interval = "100ms"
    consumer_group = "bigquery_sink_test"
    
    [plugin_config]
    project_id = "$PROJECT"
    dataset = "iggy"
    table = "events"
    verbose_logging = true
    EOF
  3. Start the server, create the stream, then start the runtime:

    bash
    IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy ./target/debug/iggy-server   # terminal 1
    ./target/debug/iggy -u iggy -p iggy stream create demo_stream   # terminal 2
    ./target/debug/iggy -u iggy -p iggy topic create demo_stream demo_topic 1 none
    IGGY_CONNECTORS_CONFIG_PATH=/tmp/bq-sink-test/config.toml ./target/debug/iggy-connectors
  4. Produce a few messages, including one bad row (terminal 3):

    bash
    ./target/debug/iggy -u iggy -p iggy message send demo_stream demo_topic '{"user_id": 1, "event": "signup", "amount": "9.99", "attrs": {"plan": "pro"}}'
    ./target/debug/iggy -u iggy -p iggy message send demo_stream demo_topic '{"event": "no user id"}'
  5. Check the rows, and look for the dropped message warning for the second message in the runtime log:

    bash
    bq --project_id="$PROJECT" query --use_legacy_sql=false \
      "SELECT user_id, event, amount, created, attrs, iggy_offset FROM iggy.events ORDER BY iggy_offset"

Overview (`core/connectors/sinks/clickhouse_sink/README.md`)

ClickHouse Sink Connector

The ClickHouse sink connector consumes messages from Iggy topics and inserts them into ClickHouse tables. Supports three insert formats: json_each_row (default), row_binary, and string passthrough.

Features

  • Multiple Insert Formats: Insert as JSONEachRow, RowBinaryWithDefaults, or raw string passthrough (CSV/TSV/JSON)
  • Schema Validation: In row_binary mode, the table schema is fetched and validated at startup
  • Automatic Retries: Configurable retry count and delay for transient errors
  • Batch Processing: Insert messages in configurable batches via the stream configuration

Configuration

toml
type = "sink"
key = "clickhouse"
enabled = true
version = 0
name = "ClickHouse sink"
path = "target/release/libiggy_connector_clickhouse_sink"

[[streams]]
stream = "example_stream"
topics = ["example_topic"]
schema = "json"
batch_length = 1000
poll_interval = "5ms"
consumer_group = "clickhouse_sink_connector"

[plugin_config]
url = "http://localhost:8123"
database = "default"
username = "default"
password = ""
table = "events"
insert_format = "json_each_row"
timeout_seconds = 30
max_retries = 3
retry_delay = 1  # seconds
verbose_logging = false

Configuration Options

Option Type Default Description
url string required ClickHouse HTTP endpoint
table string required Target table name
database string "default" ClickHouse database
username string "default" ClickHouse username
password string "" ClickHouse password
insert_format string "json_each_row" Insert format: json_each_row, row_binary, or string
string_format string "json_each_row" ClickHouse format for string mode: json_each_row, csv, or tsv
timeout_seconds u64 30 HTTP request timeout in seconds
max_retries u32 3 Total attempts for startup requests and transient insert errors; at least one even when 0
retry_delay u64 1 Base for exponential retry delay, in seconds
verbose_logging bool false Log inserts at info level instead of debug

Insert Formats

`json_each_row` (Default)

Accepts messages with a Payload::Json payload, or a Payload::Proto payload whose text is a JSON document (what a proto_convert transform hands over when it has no descriptor or cannot encode a message). Each document is serialized on its own line using ClickHouse's JSONEachRow format. Send JSON objects whose fields and values are compatible with the existing table and its ClickHouse input settings. The connector does not validate JSON rows against the table schema before sending them.

toml
[plugin_config]
url = "http://localhost:8123"
table = "events"
insert_format = "json_each_row"

`row_binary`

Accepts messages with a Payload::Json payload, or a Payload::Proto payload whose text is a JSON document. At startup the connector fetches the table schema from system.columns and validates that all column types are supported. Messages are then serialised to ClickHouse's RowBinaryWithDefaults binary format, which is more efficient than JSON for large volumes.

Requires ClickHouse 23.7 or newer, when RowBinaryWithDefaults was introduced. Older servers reject the format; use json_each_row instead.

The table must already exist. Columns with an ordinary DEFAULT expression can be omitted from the message - the connector emits a 0x01 prefix byte to signal that the default should be used. An explicit JSON null requires a nullable column and is stored as NULL, even when that column has a default. Missing columns without defaults must be nullable. The connector excludes MATERIALIZED, ALIAS, and EPHEMERAL columns from its insert schema.

The schema is captured once at startup and never refreshed. Do not ALTER TABLE the target while the connector runs. See Schema changes while running.

Supported types: the 8/16/32/64-bit integer and float primitives (Int8-Int64, UInt8-UInt64, Float32, Float64), String, FixedString(n), Bool/Boolean, UUID, Date, Date32, DateTime, DateTime64(p), Decimal (precision 1-38; Decimal256 is not supported), IPv4, IPv6, Enum8, Enum16, and the composites Nullable(T), Array(T), Map(K, V), Tuple(...). LowCardinality(T) is transparently unwrapped to its inner type T (RowBinary serialises it identically).

Unsupported types (cause startup to fail): the 128/256-bit wide integers (Int128, UInt128, Int256, UInt256), Variant, JSON (native column type), and geo types.

toml
[plugin_config]
url = "http://localhost:8123"
table = "events"
insert_format = "row_binary"

`string`

Accepts messages with a Payload::Text or Payload::Proto payload and appends a newline to each payload that does not already end with one. Set the stream schema = "text" and use string_format to tell ClickHouse which format to expect. Nothing is re-serialized in this mode, so a proto_convert transform with pretty_json = true combined with string_format = "json_each_row" is a misconfiguration: the multi-line document is written as several lines and ClickHouse rejects the insert.

toml
[plugin_config]
url = "http://localhost:8123"
table = "events"
insert_format = "string"
string_format = "csv"   # or "tsv" or "json_each_row"

Example Configs

JSON Events

toml
[[streams]]
stream = "events"
topics = ["user_events"]
schema = "json"
batch_length = 500
poll_interval = "10ms"
consumer_group = "clickhouse_sink"

[plugin_config]
url = "http://localhost:8123"
database = "analytics"
table = "user_events"
insert_format = "json_each_row"

High-Throughput with RowBinary

toml
[[streams]]
stream = "metrics"
topics = ["app_metrics"]
schema = "json"
batch_length = 5000
poll_interval = "5ms"
consumer_group = "clickhouse_sink"

[plugin_config]
url = "http://localhost:8123"
database = "telemetry"
table = "metrics"
insert_format = "row_binary"
max_retries = 5
retry_delay = 1  # seconds
verbose_logging = true

CSV Passthrough

toml
[[streams]]
stream = "exports"
topics = ["csv_data"]
schema = "text"
batch_length = 1000
poll_interval = "50ms"
consumer_group = "clickhouse_sink"

[plugin_config]
url = "http://localhost:8123"
table = "raw_imports"
insert_format = "string"
string_format = "csv"

Reliability

Insert requests retry HTTP 408, 429, and 5xx responses, plus network and timeout errors. Other unsuccessful HTTP statuses fail immediately. max_retries is the total attempt limit, with at least one attempt even when set to 0. Before retry number n (starting at 1), the delay is sampled from zero through min(retry_delay * 2^n, 60) seconds.
With the defaults, there are at most three attempts and the first retry waits between zero and two seconds. The startup ping and, in row_binary mode, schema fetch use the same limit and backoff but retry every error.

On shutdown the connector logs the total number of messages processed.

Bad rows in a batch

A message whose payload type does not match the chosen format (for example a text payload in JSON mode, or proto text that is not a JSON document) is skipped with an error log. The rest of the batch is still sent.

The row_binary format fails the whole batch on the first JSON row whose values cannot be converted to the target columns. This occurs before any insert request, so the batch does not enter the plugin's insert retry loop. No partial binary row is sent.

A batch with no serializable payloads returns success without an insert. In json_each_row and string modes, ClickHouse still validates the submitted data and can reject an insert containing a malformed row.

Schema changes while running

In row_binary mode the table schema is fetched once at startup and cached for the lifetime of the connector. The insert stream is positional: rows are written as a bare RowBinaryWithDefaults body in the column order captured at startup, and ClickHouse decodes them against the table's current column order.

An ALTER TABLE that runs while the connector is live breaks that assumption. Adding, dropping, or reordering a column shifts the byte layout by one or more columns. Depending on how the shifted bytes decode, ClickHouse either rejects the batch as malformed or, worse, stores it silently with values landing in the wrong columns. Nothing detects this at runtime, so the corruption is easy to miss.

The json_each_row format, including string mode with string_format = "json_each_row", maps values by field name, but changed column names, types, or constraints can still make inserts fail. The plain CSV and TSV string formats use the current table column order.

Until the hardening below lands, treat the row_binary schema as fixed for the connector's lifetime: restart the connector after any ALTER TABLE on the target table so it re-fetches the schema.

Two planned fixes remove the restriction:

  1. Explicit column list in the INSERT. Emitting INSERT INTO db.table (col1, col2, ...) FORMAT RowBinaryWithDefaults binds the stream to column names instead of table position. ClickHouse then routes each value by name, applies DEFAULT for columns the connector omits, and returns a hard error (instead of silently corrupting rows) when a named column has been dropped or renamed. This makes added and reordered columns safe and turns the remaining drift into a visible failure.
  2. Refresh the schema on a failed insert. When an insert fails with a data error, re-fetch the schema from system.columns and rebuild the column list before the batch is retried, letting the connector recover from a drop or rename on its own rather than failing every retry against a stale snapshot.

Delivery semantics

The runtime uses consumer auto-commit and does not replay a failed sink batch. End-to-end at-least-once delivery is therefore not guaranteed; see sink guide.

Plugin retries resend the same batch without an insert_deduplication_token, so a lost acknowledgement can also produce duplicate rows. ClickHouse deduplication depends on the table engine, query settings, identical retry data, and the retained deduplication window:

  • ReplicatedMergeTree enables a deduplication log by default, controlled by replicated_deduplication_window and replicated_deduplication_window_seconds.
  • Non-replicated MergeTree can also deduplicate when non_replicated_deduplication_window is positive; its default is zero.

See ClickHouse insert deduplication for the settings and limits. A small retry count alone does not ensure that a retry remains within the window, because other inserts can evict its deduplication record.

Testing

Integration tests against a live ClickHouse container cover only the end-to-end path from Iggy to ClickHouse: messages produced to a stream land as rows in the target table. They are intentionally not exhaustive over the wire format. They do not, for example, round-trip a UUID column, a DEFAULT / MATERIALIZED / ALIAS column, or the RowBinaryWithDefaults 0x01 use-default path against the real server.

Those cases are instead pinned by unit tests that assert exact output bytes for every supported type, including UUID word order, the 0x01 use-default prefix byte, and the schema parser dropping MATERIALIZED / ALIAS / EPHEMERAL columns while flagging DEFAULT ones.

Byte-exact unit tests plus a minimal e2e path is a deliberate trade. Containerized integration tests are costly to run, and the residual risk they would cover, a real server disagreeing with our model of the wire format, is low: the RowBinaryWithDefaults format is stable and unlikely to change under us.


Overview (`core/connectors/sinks/delta_sink/README.md`)

Delta Lake Sink Connector

The Delta Lake Sink Connector allows you to consume messages from Iggy topics and store them in Delta Lake tables.

Features

  • Support for local filesystem, AWS S3, Azure Blob Storage, and Google Cloud Storage
  • Intelligent type coercion to match Delta table schemas (e.g. ISO 8601 strings to timestamps)
  • Transactional writes with atomic flush-and-commit operations

The table must already exist. The connector appends each successful nonempty batch in one Delta transaction and keeps its schema snapshot until restart. The plugin has no failed-batch retry loop; the Delta library can retry eligible commit conflicts and storage requests. Write or commit errors clear the writer buffers and return an error. The runtime uses consumer auto-commit and does not replay failed sink batches, so end-to-end at-least-once delivery is not guaranteed.

How to configure a Delta Sink connector

First, make sure that the Delta table already exists in the location you're providing. You can use this script for an example workload:

python
import pyarrow as pa
from deltalake import DeltaTable

table_uri = "s3://my-bucket/tables/test"

schema = pa.schema([
    pa.field("user_id", pa.string(), nullable=True),
    pa.field("user_type", pa.uint8(), nullable=True),
    pa.field("email", pa.string(), nullable=True),
    pa.field("source", pa.string(), nullable=True),
    pa.field("state", pa.string(), nullable=True),
    pa.field("message", pa.string(), nullable=True),
    pa.field("created_at", pa.timestamp("us"), nullable=True),
])

DeltaTable.create(
    table_uri,
    schema,
    name="test",
    storage_options={"AWS_REGION": "us-east-1"},
)

print(f"Created table at {table_uri}")

The configuration is usually written individually for every connector and consists of two parts: the runtime settings which are registering the sink and telling which streams should plug into it, and the plugin's settings themselves. Here's an example of a working configuration:

toml
type = "sink"
key = "delta"
enabled = true
version = 0
name = "Delta Lake sink"
path = "target/release/libiggy_connector_delta_sink" # make sure you have a compiled plugin binary in this path
verbose = true

# these settings are common between all sinks
[[streams]]
stream = "your_stream"
topics = ["topic_inside_of_your_stream"]
schema = "json"
# important: read about batch and poll interval settings below
batch_length = 10000
poll_interval = "3s"
consumer_group = "delta_sink_connector"

# these settings are specific to each plugin, and in case of Delta sink, to the type of storage used
[plugin_config]
# the table must exist in the given location
table_uri = "s3://my-bucket/tables/test"
storage_backend_type = "s3"
aws_s3_region = "us-east-1"
aws_s3_allow_http = false

`[[streams]]` section

  • Find a topic that you need and the corresponding stream that this topic is in and add it into the configuration.
  • batch_length and poll_interval have to be carefully set for Delta tables. poll_interval sets the minimum gap between polls. Each poll returns immediately with whatever messages are currently available, up to batch_length. The Delta sink writes and commits exactly what one poll returns, so each Delta commit holds at most batch_length rows, and topic intake is capped at roughly batch_length messages per poll_interval per topic.
    batch_length is a ceiling: it only produces large files when messages arrive fast enough between polls to fill it, so a low-throughput topic produces small, frequent commits regardless of how high batch_length is set. For Delta, writing means creating a log entry and a separate Parquet file, so a low batch_length (roughly < 1000) or a low-throughput topic results in lots of small files.
    This is highly undesirable for the reader as it will have to read from many small files instead of a few larger ones. For query performance optimization, the system consuming these files will have to apply OPTIMIZE query in order to consolidate the files. We recommend setting batch_length pretty high based on your workload so that ideally the files are already optimized for reading.
    The guideline recommended by the developers of Delta is to keep individual file sizes between 128 MB and 1 GB. In the context of this document, it means that ideally batch_length should be tuned so that each commit's file size falls within the suggested range, assuming your topic has enough throughput to fill it. The given range is only a rough guideline and you need to find a good setting based on the patterns of reading and writing in your systems.
    A larger batch_length means a failed write loses more data: offsets commit at poll time, so if the Delta write or commit fails afterward, that whole batch is lost with no retry. It also sits in memory longer and in multiple copies — raw messages, coerced JSON, and the writer's internal buffer — until the commit succeeds.

Plugin configuration

Attributes common to all types of storage

  • table_uri (required): Absolute URI to the Delta table. Supported schemes: file://, s3://, az://, gs://. Bare filesystem paths are not accepted; local tables must use the file:// scheme.
  • storage_backend_type (optional): The cloud storage backend to use. One of "s3", "azure", or "gcs". Omit for local filesystem tables.

Local filesystem

toml
[plugin_config]
table_uri = "file:///tmp/iggy_delta_table"

AWS S3

Currently the implementation offers two possible ways of accessing the bucket.

  1. No static keys — the AWS SDK discovers credentials on its own via its default credential chain (environment variables, shared config/profile including SSO, web identity federation, ECS/EKS container credentials, or EC2 instance metadata). This is the best practice recommended by AWS. For example, if running with an attached IAM role, the role assumed by the writer should allow these actions on the bucket, here is how a working policy looks in HCL:

    If another Delta sink in the same iggy-connectors process is configured with static keys, opening it writes those keys into the shared process environment, and this keyless fallback can pick them up — which identity ends up writing depends on sink init order.

    hcl
    data "aws_iam_policy_document" "s3_write" {
      statement {
        sid    = "BucketLevelActions"
        effect = "Allow"
        actions = [
          "s3:ListBucket"
        ]
        resources = ["arn:aws:s3:::your-bucket-name"]
      }
    
      statement {
        sid    = "ObjectLevelActions"
        effect = "Allow"
        actions = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:PutObjectAcl",
          "s3:AbortMultipartUpload"
        ]
        resources = ["arn:aws:s3:::your-bucket-name/*"]
      }
    }

    The corresponding plugin configuration will look like this:

    toml
    [plugin_config]
    table_uri = "s3://my-bucket/tables/test"
    storage_backend_type = "s3"
    aws_s3_region = "us-east-1"
  2. Long-term access key pairs (access + secret key), which AWS recommends to avoid for security reasons. The example configuration:

    toml
    [plugin_config]
    table_uri = "s3://my-bucket/delta-tables/users"
    storage_backend_type = "s3"
    aws_s3_access_key = "your-access-key"
    aws_s3_secret_key = "your-secret-key"
    aws_s3_region = "us-east-1"

Parameter descriptions:

  • aws_s3_access_key: Optional. AWS access key ID. Can only be passed together with the secret key.
  • aws_s3_secret_key: Optional. AWS secret access key. Can only be passed together with the access key.
  • aws_s3_region: Required. AWS region (e.g. us-east-1).
  • aws_s3_endpoint_url: Optional. S3 endpoint URL. Use for S3-compatible services. Make sure that the URL implies the same region that is set in aws_s3_region, otherwise you'll have an error.
    Setting a custom endpoint skips the AWS SDK's full credential chain: shared profile and SSO credentials stop working, and only static keys, web identity federation, container credentials, or instance metadata remain available. Set AWS_FORCE_CREDENTIAL_LOAD=true in the iggy-connectors process environment to restore the full chain.
  • aws_s3_allow_http: Optional. Set to true to allow HTTP connections (for local development).

Azure Blob Storage

toml
[plugin_config]
table_uri = "az://my-container/delta-tables/users"
storage_backend_type = "azure"
azure_storage_account_name = "mystorageaccount"
azure_storage_account_key = "account-key"
azure_container_name = "my-container"

Required when storage_backend_type = "azure".

  • azure_storage_account_name: Azure storage account name.
  • azure_storage_account_key: Azure storage account key. Provide exactly one of this or azure_storage_sas_token.
  • azure_storage_sas_token: Shared Access Signature token. Provide exactly one of this or azure_storage_account_key.
  • azure_container_name: Azure container name.

Google Cloud Storage

toml
[plugin_config]
table_uri = "gs://my-bucket/delta-tables/users"
storage_backend_type = "gcs"
gcs_service_account_key = '{"type": "service_account", "project_id": "...", ...}'

Required when storage_backend_type = "gcs".

  • gcs_service_account_key: GCS service account JSON key (as a string). The bucket is inferred from the gs:// URI in table_uri.

Type Coercion

The connector automatically coerces JSON values to match the Delta table schema:

  • Timestamp fields: ISO 8601 / RFC 3339 formatted strings (e.g. "2021-11-11T22:11:58Z", "2021-11-11 22:11:58") are converted to microsecond timestamps. Integer epoch-microsecond timestamps pass through unchanged. Space-separated timestamps without an offset are interpreted as UTC. Invalid timestamp strings fail the batch.
  • String fields: Non-null, non-string values (numbers, booleans, objects, arrays) are converted to their string representation.
  • Nested fields: Coercions cover nested structs, arrays of strings or timestamps, and arrays of structs. Nested arrays, maps, and variant columns pass through without these coercions. Nulls remain null.

Overview (`core/connectors/sinks/doris_sink/README.md`)

Apache Doris Sink

The Doris sink connector consumes JSON messages from Iggy streams and writes them to a pre-created Apache Doris table via Doris's Stream Load HTTP API.

Requirements

  • The target Doris database and table must be pre-created before enabling the sink. The connector never issues DDL.
  • database and table config values must match [A-Za-z0-9_]+. Anything else is rejected at startup with Error::InvalidConfigValue — this also prevents path traversal in the constructed /api/{db}/{table}/_stream_load URL.
  • Messages must arrive with Payload::Json (i.e. the configured stream schema is json). If a non-JSON payload reaches the connector it logs at error! and aborts the whole poll; since the consumer offset is already committed at poll time the batch is not replayed - data loss despite the error log - so the upstream schema must be guaranteed JSON. (Under schema = "json" the SDK drops non-JSON before the connector sees it, so this abort is a defensive guard.)
  • That guard is reachable in one case: a format-converting transform runs after the decoder and can hand the connector a non-JSON variant even under schema = "json". Payload::Proto is the exception: a proto_convert transform with no descriptor hands over the JSON it was given as proto text, and the connector loads that as the document it holds. Proto text that is not JSON still aborts the poll.
  • The Iggy message JSON shape must match the target table columns. JSON fields map by name. Use the optional columns plugin setting for field mappings or derived expressions.

How it works

  1. For each batch of messages, the connector serializes the JSON payloads into the configured output format: a JSON array by default, or CSV when output_format = "csv" (see Configuration).
  2. It computes a deterministic Stream Load label of the form {label_prefix}-{stream_san}-{topic_san}-{hash16}-{partition}-{first_offset}-{last_offset}.
    • hash16 is a single 64-bit blake3 hash computed over the raw (un-sanitized), length-prefixed (label_prefix, table, stream, topic) tuple.
      Identities that sanitize to the same string therefore get distinct labels, whether the collision is in the names (events.v1 vs events_v1) or in two tenants' prefixes that truncate alike (prod_events_us_east_1 vs ..._2). Length prefixes prevent boundary-shift aliasing (("ab","c") ≠ ("a","bc")). The target table participates because Doris labels are scoped to a database, not a table.
    • The total label is bounded under Doris's 128-char cap regardless of input length (worst case 120 chars).
    • Doris dedupes loads while it retains their labels (see Operational guidance). The in-request retry (step 6) re-PUTs a transiently-failed batch under the same label, so a prior attempt that actually landed (e.g. a 2xx with a missing or unreadable body) is absorbed, not doubled.
      The runtime uses consumer auto-commit before consume() finishes and does not replay a failed poll, so a failure outliving the retry budget or a crash mid-load can lose data. Expired labels or changed chunk boundaries can also permit duplicate loads during a redrive.
  3. It PUTs the batch to {fe_url}/api/{database}/{table}/_stream_load with HTTP Basic auth, Expect: 100-continue, label: <label>, and the format headers (format: json + strip_outer_array: true for JSON; format: csv with control-char column_separator/line_delimiter + enclose/escape for CSV). Expect: 100-continue is required by Doris's Stream Load endpoint, which rejects PUTs that omit it; it also lets Doris reject auth/4xx before the body uploads.
  4. The Doris frontend (FE) responds with a 307 Temporary Redirect to a backend (BE). The connector follows the redirect manually so that the Authorization header is preserved across the hop (reqwest's default policy strips it on cross-host redirects).
    308 Permanent Redirect is also followed as a defensive measure; redirects beyond a hard cap of 5 (or a redirect with no usable Location) are rejected as a permanent PermanentHttpError, since retrying a malformed/looping redirect cannot help.
  5. The HTTP body is parsed as JSON and the Status field decides the outcome:
    • Success → batch accepted.
    • Label Already Exists with ExistingJobStatus: FINISHED → idempotent replay, treated as success. RUNNING or CANCELLED is transient and retried; a missing or unsupported existing-job status is a permanent protocol error.
    • Publish Timeout → the transaction is committed but may not yet be visible, so it is treated as success and is not retried.
    • An empty or unreadable 2xx response body → ambiguous commit outcome, retried under the same label. A non-empty malformed body remains a permanent protocol error.
    • HTTP 5xx/408/429 → transient error (Error::CannotStoreData): retried in-request up to max_retries attempts (exponential backoff + jitter) under the same label before being surfaced.
    • Fail, any other 4xx, or a non-empty unparsable response body → permanent error (Error::PermanentHttpError); never retried — re-PUTing bad data would just hammer the FE.
  6. A transient failure (the classifications above, plus a transport-level error) is retried in-request: the same batch is re-PUT under the same label, up to max_retries attempts with backoff and ±20% jitter (iggy_connector_sdk::retry). Since the runtime commits the offset at poll time, this is the connector's only redelivery path; once the budget is exhausted the final attempt's error is surfaced and the failed poll is not automatically replayed.
    The runtime logs and counts the error, adds no processed messages for that batch, and continues polling.

Configuration

Field Required Default Description
fe_url yes — Doris frontend HTTP base URL, e.g. http://localhost:8030.
database yes — Target database. Must match [A-Za-z0-9_]+.
table yes — Target table. Must match [A-Za-z0-9_]+.
username yes - Doris user authorized for Stream Load; required grants depend on the Doris version.
password yes — Doris user password. Stored as a secrecy::SecretString and never logged.
label_prefix no iggy Prefix for the deterministic Stream Load label.
batch_size no 1000 Maximum number of messages per Stream Load request; 0 is treated as 1.
timeout no 30s Per-request HTTP timeout (total request budget), as a human-readable duration (e.g. 30s, 1m).
connect_timeout no 5s TCP connect timeout, independent of timeout, as a human-readable duration. Raise it for cross-region or cold-start FEs.
max_retries no 3 Total Stream Load attempts per batch on a transient failure (0 or 1 disables retries). Each retry re-PUTs under the same label, which Doris dedupes. Values above 10 are honored but emit a startup warning because they can substantially delay graceful shutdown.
retry_delay no 200ms Base backoff before the first retry; doubles each attempt up to max_retry_delay, drawn from a ±20% window that is cut off at the cap.
max_retry_delay no 5s Strict upper bound on a single retry backoff, including jitter. The jitter window narrows as the backoff nears this cap, so the bound holds without every instance landing on it.
max_filter_ratio no unset Forwarded as the max_filter_ratio Stream Load header. Must be a finite value in [0.0, 1.0]; an out-of-range value fails open().
columns no unset Forwarded as the columns Stream Load header. Validated at startup; an invalid value fails open().
where no unset Forwarded as the where Stream Load header. Validated at startup; an invalid value fails open().
output_format no json Stream Load output format: json or csv. CSV is opt-in for throughput; it requires columns (CSV is positional, unlike name-mapped JSON) and emits control-char-framed, enclose/escape-quoted rows. open() fails if output_format = "csv" and columns is unset. List bare JSON field names first, followed by any derived expressions; CSV reads only the leading names before the first = expression.
allow_insecure_redirect no false Permit a Stream Load redirect that downgrades https:// → http://. Refused by default because it would pus