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_uringguarantee the best possible performance on modernLinuxsystems. - 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 (seeexamples/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.
## 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:
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/iggyThe CLI is on the image's PATH:
docker exec -it iggy iggy -u iggy -p iggy stream listThe 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:
helm install iggy ./helm/charts/iggy --set server.image.tag=0.9.0See 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)
export IGGY_ROOT_USERNAME=iggy
export IGGY_ROOT_PASSWORD=iggy(Windows(Powershell))
$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-serverSet custom data path
IGGY_PATH=/data/iggy cargo run --bin iggy-serverEnable HTTP transport
IGGY_HTTP_ENABLED=true cargo run --bin iggy-serverSet 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.
// 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:
cargo build --releaseStart iggy-server separately, then run the benchmarking app with the desired options:
Sending (writing) benchmark
bashcargo run --bin iggy-bench -r -- pinned-producer tcpPolling (reading) benchmark
bashcargo run --bin iggy-bench -r -- pinned-consumer tcpParallel sending and polling benchmark
bashcargo run --bin iggy-bench -r -- pinned-producer-and-consumer tcpBalanced sending to multiple partitions benchmark
bashcargo run --bin iggy-bench -r -- balanced-producer tcpConsumer group polling benchmark:
bashcargo run --bin iggy-bench -r -- balanced-consumer-group tcpParallel balanced sending and polling from consumer group benchmark:
bashcargo run --bin iggy-bench -r -- balanced-producer-and-consumer-group tcpEnd to end producing and consuming benchmark (single task produces and consumes messages in sequence):
bashcargo run --bin iggy-bench -r -- end-to-end-producing-consumer tcpEnd to end producing and consuming through a consumer group:
bashcargo 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
/* Detailed source-code truncated for AI context efficiency. */Usage
Quick Start
# 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 cleanRequirements
- Docker and Docker Compose
- The tests build the latest Iggy server from source in debug mode for faster compilation
How it Works
- Server Containers:
docker-compose.server.ymlruns a single Iggy server;docker-compose.cluster.ymladds a leader + follower pair for cluster scenarios - SDK Containers: Each SDK has its own container with the appropriate runtime and dependencies
- Shared Features: All SDKs test against the same
.featurefiles for consistency - 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):
- Create
node/directory - Add
node/Dockerfilewith appropriate runtime and dependencies - Create
node/tests/directory with BDD implementation - Add
node-bddservice todocker-compose.yml - Update
../scripts/run-bdd-tests.shscript - 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
- Opening
- Table of Contents
- When you need to follow this process
- Before creating an RFC
- What the process is
- RFC numbering
- The RFC life-cycle
- Help this is all too informal
- License
- Contributions
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.mdtorfcs/rfc-0-my-feature.md(where
"my-feature" is descriptive). Don't assign an RFC number yet;0marks 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 requestS-waiting-on-authoruntil 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 titledRFC: <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.
- This step is taken when enough of the tradeoffs have been discussed that
- 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 examplerfcs/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 pnpmandyarnare not part of the supported workflow for this package. CI, Docker builds, and the committed lockfile usenpm.
Getting Started
Run Iggy server first
shdocker pull apache/iggy:edgeshdocker 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:edgeClone the repository:
shgit clone https://github.com/apache/iggy.gitBuild the project:
shcd iggy/web npm ciRun the project:
shnpm run devTo expose port in local network run:
shnpm run dev -- --host --port 3333Set
PUBLIC_IGGY_API_URLinweb/.envto the HTTP address reachable by your browser, for example:shPUBLIC_IGGY_API_URL=http://127.0.0.1:3000This 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./skillanswers 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
pr-skill-review-run.ymlruns onissue_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 writesfindings.json.pr-skill-review-post.ymlruns onworkflow_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 nonedenies 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 inDEEPSEEK_MODEL. Both pins live in one job-levelenvblock, 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
/procis 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: falseand 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_commentevent, 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. .claudeand.agentsare 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 forAGENTS.mdandCLAUDE.mdat any depth, which the CLI loads as its own orders.- Project configuration files, hooks and MCP servers never load.
--restrictedignores the project configuration files, and--strict-mcp-configwith 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: APPROVEorVerdict: REQUEST CHANGESthat opens a sentence. The verdict reaches the pull request only throughflip_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:
# 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 tcpThese 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
- Record the server and benchmark revisions, build profile, kernel, VM type, CPU allocation, allocator, memory limits, filesystem, and provisioned disk/network capacity.
- Keep workload shape, both durability policies, replication-group size, and topic options identical between compared runs. Use an explicit warmup and repeat runs.
- 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. - 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.
- 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:
iggy-bench --warmup-time 10s --total-data 10GiB balanced-producer tcp output --identifier baseline --remark replicated-defaultsChoose --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.
Links
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
cargo install iggy-cliRun iggy --help for the full command tree.
Links
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.
Links
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.
Build the server, CLI, runtime and quick-start plugins:
bashcargo build --release -p server -p iggy-cli -p iggy-connectors \ -p iggy_connector_random_source -p iggy_connector_stdout_sink \ -p iggy_connector_quickwit_sinkFor a debug build, omit
--releaseand replacetarget/releasewithtarget/debugin both the commands and plugin paths. Make sure that the plugins specified incore/connectors/runtime/example_config/connectors/directory underpathare available. The configuration must be provided intomlformat.
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 theErrorstatus, but they don't block the remaining connectors. Setenabled = falsein their files to skip them entirely.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 theeventsindex will be created.In the terminal that will run the connectors, set the runtime configuration path:
bashexport IGGY_CONNECTORS_CONFIG_PATH=core/connectors/runtime/example_config/config.tomlStart the Iggy server in a separate terminal with credentials matching the sample connector configuration:
bashIGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-server --releaseWith 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.
bashtarget/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 1dExecute
cargo run --example sink-data-producer --release, which sends 100 batches of messages to previously createdqwstream andrecordstopic (this will be used by the Quickwit sink connector).Start the connector runtime
cargo run --bin iggy-connectors --releasein the terminal configured in step 3. The Quickwit sink indexes the produced records in theeventsindex. At the same time, you should see the new messages being added to theexample_streamstream andexample_topictopic 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:
Extension — if the path has no recognized extension, the OS-native one is appended automatically (
.soon Linux,.dylibon macOS,.dllon Windows).Absolute paths — used as-is.
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:
# 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
IggyClientAPI 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 onconnect(); 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), andconsumer_group(name, stream, topic). - Producer modes:
direct(awaited send) andbackground(buffered with parallel shard workers usingOrderedShardingorBalancedSharding). Configurable batch length / size and linger time. - Partitioning:
balanced,messages_key, or explicitpartition_id. CustomPartitioneris 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 (viaAes256GcmEncryptor), topic compression metadata (NoneandGzip; 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.
cargo add iggyAll 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. AnIggyError::OperationUncertain(operation_id) retains the exact request for
recovery. A later mutation on the same lane returnsIggyError::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. AnotherOperationUncertain 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. AnOperationNotRetained 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:
iggy://iggy:iggy@localhost:8090?max_pending_operations=256&max_pending_operation_bytes=67108864Admission 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:
cargo run --bin iggy-server -- --fresh --with-default-root-credentialsUse 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.
/* 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
- Rust SDK docs
- High-level SDK guide
- Stream builder guide
- Project documentation
- Runnable examples: getting-started, basic, new-sdk, stream-builder, multi-tenant, message-envelope, message-headers, tcp-tls, sink-data-producer.
- Benchmarking platform
- GitHub repository
- Discord community
Related crates
iggy_common: shared types and traits.iggy_binary_protocol: wire protocol codec.iggy-cli: command-line tool,cargo install iggy-cli.
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
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=... cargo run --bin iggy-server --releaseThe 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:
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:latestUse 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:
cargo run --bin iggy-server --release -- --replica-id 0Command-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 toIGGY_HTTP_JWT_TRUSTED_ISSUERS_<n>_USERNAME.
Cluster metadata includes replica_id. Java's ClusterNode record adds a leadingreplicaId 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 requiresreplicaID. C++'s FFI ClusterNode, Rust's iggy_common::ClusterNode andiggy_binary_protocol::responses::system::ClusterNodeResponse add the publicreplica_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:
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_HTTP_ENABLED=false cargo run --bin iggy-serverCluster 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 requirePersisted; crash-safe explicit offset retries require Persisted andQuorum. 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 withPartitionResizeDisabled (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'soptions 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 leastcreated_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:
- Pause writes to the affected partition and stop the affected cluster before
changing any recovery files. Keep the refusal logs. - Preserve a consistent copy of every replica's metadata, partition
superblocks, WAL directories and segment files together with its configuration. - 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 inpartition-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:
cargo build --bin iggy-server --release --features systemdThe 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
# 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-uiNothing 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:
- Fire the consensus tick timer (
CONSENSUS_TICK_INTERVAL: view change, retransmits) and run every pump to quiescence. - 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
--shella client packet enters throughdeliver_client_requestinstead of rawdispatch. - Run the pumps again over the delivered frames and their loopback follow-ups, then drain each replica's outbox into the network.
- 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
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
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
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 30000Omitting --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:
replica 2: live | metadata ... commit=1407..1414 barrier=1359 transferring=false primary=false repair=..1407@0Tests
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_writesandset_yield_writesinject a persistent write fault and an fsync-wide suspension point;MemStorageunder 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
RetainedPartitionStateacross 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
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 runCI
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:
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-serverFor an existing server, use its configured credentials. Run each producer before its consumer.
For server configuration options and help:
cargo run --bin iggy-server -- --helpYou can also customize the server using environment variables:
# 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-serverBasic Examples
Getting Started
Perfect introduction for newcomers to Iggy:
dotnet run --project examples/csharp/src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Producer
dotnet run --project examples/csharp/src/GettingStarted/Iggy_SDK.Examples.GettingStarted.ConsumerThese examples use IIggyClient with TCP transport and demonstrate stream/topic creation with basic message handling.
Basic Usage
Core functionality with detailed configuration options:
dotnet run --project examples/csharp/src/Basic/Iggy_SDK.Examples.Basic.Producer
dotnet run --project examples/csharp/src/Basic/Iggy_SDK.Examples.Basic.ConsumerDemonstrates fundamental client connection, authentication, batch message sending, and polling over TCP or HTTP.
Message Pattern Examples
Message Headers
Shows metadata management using custom headers:
dotnet run --project examples/csharp/src/MessageHeaders/Iggy_SDK.Examples.MessageHeaders.Producer
dotnet run --project examples/csharp/src/MessageHeaders/Iggy_SDK.Examples.MessageHeaders.ConsumerDemonstrates 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:
dotnet run --project examples/csharp/src/MessageEnvelope/Iggy_SDK.Examples.MessageEnvelope.Producer
dotnet run --project examples/csharp/src/MessageEnvelope/Iggy_SDK.Examples.MessageEnvelope.ConsumerUses 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:
dotnet run --project examples/csharp/src/TcpTls/Iggy_SDK.Examples.TcpTls.Producer
dotnet run --project examples/csharp/src/TcpTls/Iggy_SDK.Examples.TcpTls.ConsumerUses 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:
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-serverExample Structure
All examples can be executed directly from the repository. Follow these steps:
- Start the matching server with the credentials shown above
- Run the producer and consumer using the concrete commands above
- 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:
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:
cargo run --bin iggy-server -- --helpYou can also customize the server using environment variables:
## 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-credentialsEach 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:
go run ./getting-started/producer/main.go
go run ./getting-started/consumer/main.goTLS Examples
From the repository root, start a disposable TLS server with the development certificate pair and the same credential prerequisites:
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-credentialsThese certificates are for development only. From examples/go, run:
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.pemExample Structure
All examples can be executed directly from the repository. Follow these steps:
- 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. - Run the producer, then the consumer: use the commands above from
examples/go. - 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:
export IGGY_ROOT_USERNAME=iggy
export IGGY_ROOT_PASSWORD=iggy
cargo run --bin iggy-server -- --fresh --with-default-root-credentialsPowerShell environment variables:
$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:
## Example: set a custom TCP address
IGGY_TCP_ADDRESS=127.0.0.1:8090 cargo run --bin iggy-server -- --with-default-root-credentialsBasic Examples
Getting Started
A good introduction for newcomers to Iggy:
./gradlew runGettingStartedProducer
./gradlew runGettingStartedConsumerMessage Headers
Shows metadata management using custom headers:
./gradlew runMessageHeadersProducer
./gradlew runMessageHeadersConsumerUses a message_type header to choose the application handler for each order event.
Message Envelopes
JSON envelope pattern for polymorphic message handling:
./gradlew runMessageEnvelopeProducer
./gradlew runMessageEnvelopeConsumerUses 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:
./gradlew runMultiTenantProducer
./gradlew runMultiTenantConsumerFeatures 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:
./gradlew runSinkDataProducerProduces 100 batches of 1000 to 1099 messages with generated user records.
Stream Builder Examples
Stream Builder
Producing and consuming messages in one class:
./gradlew runStreamBasicUses 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:
./gradlew runAsyncProducerShows:
- CompletableFuture chaining patterns
- Submitting multiple sends without blocking
Async Consumer
Non-blocking async consumption with advanced patterns:
./gradlew runAsyncConsumerShows:
- 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()insidethenApply/thenAcceptThread.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:
./gradlew runTcpTlsProducer
./gradlew runTcpTlsConsumerThese examples require a TLS-enabled Iggy server. From the repository root, start a disposable server with the development certificates:
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-credentialsUses 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
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
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
// 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:
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:
cargo run --bin iggy-server -- --helpYou can also customize the server using environment variables:
# 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-credentialsFrom the repository root, build the local SDK before installing the examples:
npm --prefix foreign/node ci
npm --prefix foreign/node run build
npm --prefix examples/node ci
cd examples/nodeRun 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:
npm run test:getting-started:producer
npm run test:getting-started:consumerBasic Usage
Core functionality with detailed configuration options:
npm run test:basic:producer
npm run test:basic:consumerDemonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP transport.
Message Envelope
Working with message envelopes:
npm run test:message-envelope:producer
npm run test:message-envelope:consumerDemonstrates a JSON envelope containing message_type and a serialized payload.
Message Headers
Dispatching by a message type stored in the payload:
npm run test:message-headers:producer
npm run test:message-headers:consumerThis example stores messageType and data in a JSON payload wrapper.
Multi-Tenant
Multi-tenant application patterns:
npm run test:multi-tenant:producer
npm run test:multi-tenant:consumerEach 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:
npm run test:stream-builderUses the standard client API to send and consume three messages, then deletes the resources it created.
Sink Data Producer
Generating messages for sink connectors:
npm run test:sink-data-producerProduces 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:
npm run test:tcp-tls:producer
npm run test:tcp-tls:consumerThese examples require a TLS-enabled Iggy server. From the repository root:
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-credentialsThe 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
npm run test:unit
npm run lintOverview (`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:
cargo run --bin iggy-server -- --fresh --with-default-root-credentialsFrom examples/php, build the PHP extension and point PHP at it:
(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
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.phpBasic Usage
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.phpThe examples use IGGY_CONNECTION_STRING when it is set; use a binary transport
with credentials for automatic login. Otherwise they connect over TCP usingIGGY_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:
# 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 --freshThe 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:
cargo run --bin iggy-server -- --helpYou can also customize the server using environment variables:
# 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-credentialsWith Python 3.11 or newer and Rust/Cargo available, install dependencies fromexamples/python. uv selects the local SDK path in pyproject.toml; pip needs
that path explicitly:
# 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:
# 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.pyThe 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:
# 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.pyBasic Usage
Core functionality with detailed configuration options:
# Using uv
uv run basic/producer.py
uv run basic/consumer.py
# Without using uv
python basic/producer.py
python basic/consumer.pyDemonstrates 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 convenientdict[str, str | bytes | bool | int | float]form; the SDK infers a wire type for each value.typed-headers/uses explicitHeaderKey/HeaderValuefor 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.
# 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.pyTLS Examples
To test with a TLS-enabled server, start the server with TLS configured (see main README), then run:
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.pemOverview (`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:
Start the server with default credentials:
bashcargo run --bin iggy-server -- --with-default-root-credentialsSet the appropriate environment variables before starting the server with
cargo run --bin iggy-server:macOS/Linux:
bashexport IGGY_ROOT_USERNAME=iggy export IGGY_ROOT_PASSWORD=iggyWindows(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:
Delete the existing data directory, then start the server again with the default-credential flag or environment variables.
Use the
--freshflag to force a reset:bashcargo run --bin iggy-server -- --with-default-root-credentials --freshThis 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:
cargo run --bin iggy-server -- --helpYou can also customize the server using environment variables:
## 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-credentialsYou 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:
cargo run --example getting-started-producer
cargo run --example getting-started-consumerThese 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:
cargo run --example basic-producer
cargo run --example basic-consumerDemonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP/QUIC/HTTP/WebSocket protocols.
To run the pair over HTTP:
cargo run --example basic-producer -- \
--transport http
cargo run --example basic-consumer -- \
--transport httpMessage Pattern Examples
Message Headers
Shows metadata management using custom headers:
cargo run --example message-headers-type-producer
cargo run --example message-headers-type-consumerDemonstrates 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:
cargo run --example message-headers-compression-producer
cargo run --example message-headers-compression-consumerDemonstrates typed header keys and values with various data types (strings, integers, floats, booleans, raw bytes):
cargo run --example typed-headers-producer
cargo run --example typed-headers-consumerMessage Envelopes
JSON envelope pattern for polymorphic message handling:
cargo run --example message-envelope-producer
cargo run --example message-envelope-consumerUses 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:
cargo run --example multi-tenant-producer
cargo run --example multi-tenant-consumerFeatures 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:
cargo run --example new-sdk-producer
cargo run --example new-sdk-consumerShowcases the newer SDK APIs with simplified setup, automatic topic creation, consumer groups, and AutoCommit configuration.
High-Volume Data Generation
Testing and benchmarking support:
cargo run --example sink-data-producerProduces 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:
cargo run --example stream-basic
cargo run --example stream-producer
cargo run --example stream-consumerAdvanced Configuration
Comprehensive configuration examples with detailed documentation:
cargo run --example stream-producer-config
cargo run --example stream-consumer-configThese 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:
cargo run --example tcp-tls-producer
cargo run --example tcp-tls-consumerThese examples require a TLS-enabled Iggy server. Start the server with:
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-credentialsUse 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:
- Start the Iggy server using the credential setup above
- Run desired example:
cargo run --example EXAMPLE_NAME - 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
# Build library
bazel build //:iggy-cpp
# Unit tests
bazel test //:unit
# Low-level integration tests (require a running server)
bazel test //:e2eBinary mutations retain an uncertain operation until it is resolved or abandoned.IggyException::OperationId() carries an exact std::uint64_t foroperation_uncertain, lane_blocked, and operation_not_retained.
Keep using the same client to recover:
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, andAbandonOperation.CreateUser,UpdateUser, andDeleteUser.CreateStream,UpdateStream, andDeleteStream.CreateTopic,UpdateTopic, andDeleteTopic.CreatePartitions,DeletePartitions, andDeleteSegments.CreateConsumerGroup,DeleteConsumerGroup,JoinConsumerGroup, andLeaveConsumerGroup.StoreConsumerOffset,StoreConsumerPosition, andDeleteConsumerOffset.
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, whenhas_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 withBuilder().WithPendingOperations(max_operations, max_bytes). Both must be positive.FromConnectionString accepts the same limits as max_pending_operations andmax_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:
dotnet add package Apache.Iggy --version 0.9.0The 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:
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 toNullLoggerFactory.Instance). Add Microsoft.Extensions.Logging.Console to use AddConsole:
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:
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:
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 waitsReconnectionSettings.WaitAfterReconnect (1 second by default). Credentials are taken literally: they are
not percent-decoded and must not contain @ or :. Bracketed IPv6 hosts such asiggy://iggy:iggy@[::1]:8090 are accepted. Durations look like 500ms, 5s or 1m30s. Only iggy:// andiggy+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.
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.
AStaleClienteviction also removes only the binding of that link. The reconnect binds the same session
again, so its consumer-group memberships stay.
A committedStaleClientresult 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. AStaleClientrefusal 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 withUnauthenticatedand 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 (UnauthenticatedorStaleClient) 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 usesBaseAddressinstead 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.
JoinConsumerGroupAsyncmakes this client a member; the assignment
is synced on demand and refreshed on everyPingAsync. 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. PingAsynccosts 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
everyHeartbeatInterval(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.
IggyInvalidStatusCodeExceptioncarries the server status.FromServerdistinguishes a server verdict
from a local error, andCommittedidentifies a proved committed result.VsrRequestOutcomeUnknownException.OperationIdidentifies a retained replicated mutation after its call
ends without proof. Its lane stays blocked.VsrLaneBlockedException.OperationIdidentifies 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 lastMaxPendingOperationsreplays, 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 againstMaxPendingOperationsat once.
AResolveOperationAsyncthat is still running is not canceled and keeps the lane until it ends, so later
mutations of the lane still wait behind its replay.VsrOperationNotRetainedExceptionmeans 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 throwVsrLaneBlockedException. 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 backgroundIggyPublisher 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 throwFeatureUnavailableExceptionon HTTP. StoreOffsetAsync/DeleteOffsetAsyncneed an explicit partition id under VSR: the broker does not
resolve anullpartition 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.FlushUnsavedBufferAsyncwas removed. SetTopicOptions.DurabilitytoDurability.Persistedwhen creating a topic to wait for message persistence.MaxResponseFrameSizebounds 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
GetSnapshotAsyncis the usual case.
- a large
- Clients built with
IggyConsumerBuilder/IggyPublisherBuildernow auto-login with the credentials passed
toWithConnection. 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.MaxRetriesbounds 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 andDisposestop 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 thatResolveOperationAsyncproves. A committed rejection leaves them unchanged. AutoLoginSettingsproperties are nowinit-only, as isIggyClientConfigurator.HeartbeatInterval. Build
them with an object initializer or theAutoLoginSettings.For/AutoLoginSettings.ForPersonalAccessToken
factories instead of assigning after construction.IggyConsumerBuilder/IggyPublisherBuilderaccept a personal access token through theWithConnection(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
NoDelayto 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:
var response = await client.LoginUserAsync("iggy", "iggy");Creating Users
Create new users with customizable permissions:
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:
// 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
await client.CreateStreamAsync("my-stream");You can reference streams by either numeric ID or name:
var streamById = Identifier.Numeric(0);
var streamByName = Identifier.String("my-stream");Creating Topics
Every stream contains topics for organizing messages:
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:
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:
// 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:
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:
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.
// 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:
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:
// 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:
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:
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:
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:
var metadata = await client.GetClusterMetadataAsync();Server Statistics
Retrieve server performance metrics:
var stats = await client.GetStatsAsync();Health Checks
Verify server connectivity:
await client.PingAsync();Client Information
Get information about connected clients:
var clients = await client.GetClientsAsync();
var currentClient = await client.GetMeAsync();Snapshots
Capture a system snapshot as a compressed ZIP archive:
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:
await client.DeleteSegmentsAsync(
Identifier.String("my-stream"),
Identifier.String("my-topic"),
partitionId: 1,
segmentsCount: 2
);Event Subscription
Subscribe to connection events:
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:
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:
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:
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):
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:
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):
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy cargo run --bin iggy-serverRun an example from the examples/csharp/ directory. Run TcpTls from the repository root so its
certificate paths resolve:
dotnet run -c Release --project src/GettingStarted/Iggy_SDK.Examples.GettingStarted.Producer
dotnet run -c Release --project src/GettingStarted/Iggy_SDK.Examples.GettingStarted.ConsumerIntegration 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_HOSTenvironment 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.
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
dotnet build foreign/csharp/Iggy_SDK.Tests.Integration3. Run test
cd foreign/csharp
export IGGY_SERVER_DOCKER_IMAGE=iggy-server:test
dotnet test -f net10.0 --project Iggy_SDK.Tests.Integration --no-build --verbosity diagnosticIGGY_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 CoreDependency 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:
go get github.com/apache/iggy/foreign/goUnversioned 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:
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-serverQUIC, 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 exposeserrors.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 exportedIggyTcpClient.HandleLeaderRedirection method has been removed; replica routing
and reconnection are managed by the client.
If the application accepts the uncertainty, callAbandonOperation(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:
go test ./...The end-to-end suite runs against a server at the address inIGGY_TCP_ADDRESS and skips when that variable is unset:
IGGY_TCP_ADDRESS=127.0.0.1:8090 go test ./testsAdd 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:
cargo build --bin iggy-server --bin iggy
cargo test -p integration given_split_primaries_when_go_group_ -- --ignored --test-threads=1To 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:
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_SplitPrimaryPollsPreserveCoordinatorMembershipContributing
Before creating a pull request, please run golangci-lint and fix any reported lint issues:
golangci-lint runOverview (`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:
implementation 'org.apache.iggy:iggy:0.9.0'Maven:
<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:
repositories {
mavenCentral()
maven {
url = uri("https://repository.apache.org/content/repositories/snapshots/")
}
}
dependencies {
implementation 'org.apache.iggy:iggy:0.9.1-SNAPSHOT'
}Maven:
<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. UseCommandCode.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)
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:
// 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 thetls 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)
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
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:
// 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:
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:
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
// 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 HTTPException 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 carriesIggyErrorCode.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) andabandonOperation(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 withOPERATION_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.
# Build the project
./gradlew build
# Run tests
./gradlew testThe wrapper script will:
- Download
gradle-wrapper.jarfrom the official Gradle repository if missing - Verify the SHA256 checksum for security
- 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:
- Format code:
./gradlew spotlessApply - Validate build:
./gradlew check - 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
npm i --save apache-iggy@edgeUse 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:
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 resolvesPartitioning.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 withOperationUncertainError. 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 withOperationUncertainError.
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 withNotConnectedError. 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 andgroupConsumerStream poll again after interval. They also poll again after a
poll deadline, after a poll with unknown progress and afterNotConnectedError. 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 isNotConnectedError. If the 30 second sign-in deadline ends first, the error isVsrResponseTimeoutError. 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.
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.
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:
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 existingRawClient 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 and0, unlimited, disabled and none map to zero. Unit-less numbers such as5 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
npm cibuild
npm run buildtest
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
npm run test:unite2e tests
e2e test expect an iggy-server at tcp://127.0.0.1:8090
npm run test:e2ebdd 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
./scripts/run-bdd-tests.sh nodethe 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
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
npm run testlint
npm run lintOverview (`foreign/php/README.md`)
iggy-php
PHP extension bindings for Apache Iggy, built in Rust withext-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:
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-configBuild
cargo build --releaseGenerate IDE stubs after changing the exported PHP API:
cargo build
cargo php stubs target/debug/libiggy_php.so -o /tmp/iggy-php.stubs.phpStub 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
cargo php install --release --yesIf the extension is already enabled, reinstall it with:
cargo php remove --yes
cargo php install --release --yesVerify PHP can load it:
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.
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.0You can also run a local server from the repository root:
cargo run --bin iggy-server -- --fresh --with-default-root-credentialsThe 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
$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
$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:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from php-testsRun the PHP test suite:
composer install
composer testRun Rust verification with a matching PHP embedding library (libphp) available
to the linker and dynamic loader:
cargo test --features ext-php-rs/embedTLS 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. SetIGGY_TLS_PLAINTEXT_ADDRESS to run the negative plaintext-to-TLS test.
TLS connection strings use the Rust SDK connection-string format, for example:
iggy+tcp://iggy:[email protected]:8090?tls=true&tls_domain=localhost&tls_ca_file=/path/to/ca.pemAPI 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 acceptmax_pending_operationsandmax_pending_operation_bytes, for exampleiggy+quic://iggy:iggy@localhost:8080?max_pending_operations=256&max_pending_operation_bytes=67108864.Iggy\Exception\PendingOperationExceptionpreserves the read-onlyoperation_id
decimal string andkind(uncertain,lane_blocked, ornot_retained).getCode()returns the SDK error code. Keep IDs as strings: PHP integers cannot
represent the full unsigned 64-bit range.lane_blockedidentifies an earlier
operation, and the blocked call was not sent.not_retaineddoes 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 lastmax_pending_operationsreplays, also across a session reset. A laterresolveOperation()of a proved operation returns the same outcome without a request.- Methods are exposed to PHP as camelCase, for example
createStream()andpollMessages(). - Classes live in the
Iggynamespace, for exampleIggy\ClientandIggy\SendMessage. - Partition IDs use the Iggy partition index. For a topic with one partition, use
0. - Passing
nullas the partition tostoreOffset()ordeleteOffset()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 PHPIteratorand can be used withforeach.
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, useAutoCommit::disabled()and callstorePosition($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'sposition()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 examplePollingStrategy::offset($message->offset() + 1)->withContext($message->context()).
A stale context fails the poll. To continue in another process, save the context'sincarnation,owner_generationandmetadata_opwith the offset and rebuild it withnew Iggy\PartitionContext(...).- A poll or offset write without a caller context uses the context of its route. It can
fail once withHistoryUnavailable(87), orConsumerGroupPartitionNotOwned(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 asIggy\Exception\IggyException. Iggy\PollingStrategy::timestamp()andIggy\PollingStrategy::timestampMicros()
expect microseconds since the Unix epoch. UseIggy\PollingStrategy::timestampSeconds()for PHPtime()values.- PHP strings are passed as named identifiers, including strings that contain
only digits. PHP integers are passed as numeric identifiers. Iggy\SendMessage::payloadandIggy\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 toPHP_INT_MAX. Iggy\Client::sendBinaryRequest(int $code, string $payload): stringsends a
command code and payload and returns the raw response body.Iggy\Clientis 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
# Using uv in an existing project
uv add apache-iggy
# Using pip
python3 -m venv .venv
source .venv/bin/activate
pip install apache-iggyPrerequisites
- 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.
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 developWith
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 developRun the server to be able to run the tests (this blocks the terminal - run steps 3-5 in a separate one).
--freshdeleteslocal_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 --freshRun the tests
uv:bashuv run --no-sync pytest tests/ -vpip:bashpytest tests/ -v # make sure iggy-server is running and the venv is activatedTo update the stubs, after changing the pyo3 API surface, use
bash# run from foreign/python cargo run --bin stub_genBefore committing, test the pre-commit and pre-push hooks.
prekonly inspects staged content, so stage your work first:bashgit 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.bashuv run --no-sync ruff format .bashuv run --no-sync ruff check --fix .bashcargo fmt --manifest-path Cargo.tomlbashcargo clippy --manifest-path Cargo.toml --all-targets --all-features -- -D warningsbash# 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, anHttpConfig, or a WebSocketConfig:
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, anHttpConfig 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 noAutoLogin: connect() does not dial over HTTP, but it does start the
heartbeat that heartbeat_interval configures, so call it and thenlogin_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 directpoll_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.
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.
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(). Usesend_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 acceptssend_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 numericcode, 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 andmax_pending_operation_bytes as positive integers, for exampleiggy+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. Itscommitted 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 itsoperation_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, andnot_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 aSendMessagesResponse with an empty confirmations list. It does not mean the
server has committed the messages.
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, andfail_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 runnablebackground_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, callawait producer.shutdown() in a finally block. Shutdown waits for active
sends, is safe to call more than once, and rejects later sends withRuntimeError. 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-serverfrom 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:
// 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:
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:
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 nextconnect(), 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:
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:
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:
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 isPolledMessages.noAssignedPartition, and a client that is not a member getsconsumerGroupMemberNotFound. 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:
func handle(_ error: IggyError) {
switch error.code {
case .streamNameNotFound:
print("create the stream first")
default:
print("iggy failed: \(error)")
}
}Testing
cd foreign/swift
swift testThe 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:
cargo build --bin iggy-server
IGGY_PATH=/tmp/iggy-swift IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy target/debug/iggy-serverThen, from foreign/swift:
IGGY_TCP_ADDRESS=127.0.0.1:8090 swift test --filter IggyE2ETestsAdd IGGY_TCP_TLS_ENABLED=true to run it against a server started withIGGY_TCP_TLS_ENABLED=true and the certificate pair in core/certs.
The hash, error-code, model, request, reply, and consensus header tests compare againstTests/IggyTests/Fixtures/golden.json, a fixture dumped from the Rust protocol crates. Regenerate it after a protocol change, fromforeign/swift, and commit the result; CI fails when the checked-in copy is stale:
cargo run --manifest-path Tools/golden-vectors/Cargo.toml -- Tests/IggyTests/Fixtures/golden.jsonContributing
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:
swift format format --in-place --recursive Sources Tests Package.swift
swift format lint --strict --recursive Sources Tests Package.swift
swift build -Xswiftc -warnings-as-errorsOverview (`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 retriableNOT_LEADER_OR_FOLLOWER(6), CreateTopics,
DeleteTopics, CreatePartitions, DescribeConfigs, AlterConfigs and IncrementalAlterConfigs answerNOT_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 andIGGY_KAFKA_SASL_ENABLEDoff (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'sretention.mslow enough that the cleaner drops sealed segments, deleting data. WithIGGY_KAFKA_AUTO_CREATE_TOPICS_ENABLED=trueand no listener that names principals, any client
that reaches this port can also create topics just by naming them in aMetadatarequest. When
the connection has a principal (SASL, or a client certificate mapped to an Iggy user),DeleteTopics,CreatePartitions,AlterConfigs,IncrementalAlterConfigsandMetadata
auto-creation additionally require it to hold a globalmanage_topicsormanage_streams;
without it every resource of aDeleteTopics,CreatePartitions,AlterConfigsorIncrementalAlterConfigsrequest, and every missing topic aMetadatarequest would create,
failsTOPIC_AUTHORIZATION_FAILED(29) and nothing is written, created or deleted.CreateTopicshas no such gate yet - SASL on does not protect it. Every client reads and writes
as the bridge's Iggy user. Iggy'smetadata.partitions_maxdefaults 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,DescribeGroupsandListGroupsare 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 --membersand--describe --stateuse 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 onauto.offset.reset=latestcan skip records until
#4409.
Run
cargo run -p iggy-gateway-kafkaDefault 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.)
Kafka wire (TCP 9093) Iggy wire (TCP 8090)
kcat / librdkafka ─────────────────────▶ gateway ─────────────────────▶ iggy-server
(your Kafka client) iggy-gateway-kafka (real broker)cd gateways/kafka
docker compose up --buildiggy-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:
# Broker discovery works with just kcat - it has no create-topic mode of its own, though.
kcat -b 127.0.0.1:9093 -LA full create-topic-then-produce flow needs a client that can send CreateTopics, unlessIGGY_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):
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:
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:
docker compose exec iggy-server /usr/local/bin/iggy --username iggy \
--password iggy-gateway-quickstart message poll kafka orders 0 --offset 0 --message-count 1Ports: 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, rundocker 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
cargo test -p iggy-gateway-kafkaSee 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):
./gateways/kafka/scripts/ci-wire-fixtures.sh generate
cargo test -p iggy-gateway-kafka
./gateways/kafka/scripts/ci-wire-fixtures.sh cleanup # optionalOr generate only the keys the tests need:
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"
doneManual 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
- docs/BRIDGE_MAPPING.md — how a Kafka record becomes an Iggy message, and back
- docs/IDEMPOTENCE.md — InitProducerId, and why delivery is at-least-once
- docs/OFFSET_STORAGE.md — where Kafka consumer group offsets live
- docs/CONSUMER_GROUPS.md — group membership, rebalances, and why one gateway per bootstrap endpoint
- docs/AUTHENTICATION.md — how a Kafka client authenticates, and why PLAIN only
- docs/ACL_MAPPING.md — how Iggy permissions are described as Kafka ACLs
- docs/CONFIGS.md — DescribeConfigs, AlterConfigs and IncrementalAlterConfigs for topic
retention.ms
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.idis accepted but not honoured. A static member that restarts causes two
rebalances.- No DescribeGroups or ListGroups yet (#3548), so
kafka-consumer-groups.shcannot 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.
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-kafkaEach 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 inIGGY_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.
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 -LClient 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 throughIGGY_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 aPLAINTEXT listener beside it too. The requests the gateway checks against a principal,DeleteTopics, AlterConfigs and the auto-creation a Metadata request asks for, needmanage_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:
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.
IGGY_KAFKA_SASL_ENABLED=true IGGY_KAFKA_IGGY_ADDR=127.0.0.1:8090 cargo run -p iggy-gateway-kafkaTransport 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:
kcat -b 127.0.0.1:9093 -X security.protocol=SASL_PLAINTEXT -X sasl.mechanisms=PLAIN \
-X sasl.username=alice -X sasl.password=s3cret -LFour things to know before switching it on:
- PLAIN sends the password in the clear unless the hop is encrypted: a
SASL_SSLlistener on
the Kafka side,IGGY_KAFKA_IGGY_TLS_ENABLED=trueon the Iggy side. ASASL_PLAINTEXTlistener
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
ApiVersionsadvertisement 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_AUTHENTICATIONSbounds 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, sokafka-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 thetests/*_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 answer0with 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 atwarn. - 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_bytesgets 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.ListOffsetsanswers 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 streamIGGY_KAFKA_IGGY_STREAM (default kafka), topic orders - the Kafka topic name carries over
unchanged. Override specific topics with a TOML file:
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 tokafka. When both IGGY_KAFKA_TOPIC_MAP_PATH and IGGY_KAFKA_IGGY_STREAM are set, the file's owndefault_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 returnsBridgeError::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 andpoll_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 withauto.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
Metadatarequest that names a missing topic and allows auto-creation (always below v4, the
request'sallow_auto_topic_creationfrom v4) starts creating it withIGGY_KAFKA_AUTO_CREATE_TOPICS_PARTITIONSpartitions and answersUNKNOWN_TOPIC_OR_PARTITION
(3), as Kafka does. The client finds the topic on its next refresh. - A
Metadatarequest 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_stateand__share_group_state, are answeredUNKNOWN_TOPIC_OR_PARTITIONand 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,
orANONYMOUSbeside either), it needs a globalmanage_topicsormanage_streams. Only global
grants count:manage_topicsgranted on one stream does not, even where Iggy would let the
principal create the topic. A principal without it is answeredTOPIC_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 setallow.auto.create.topics=false, which keeps its lookups of a missing topic answeredUNKNOWN_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 explicitCreateTopicsis not checked againstmanage_topics
today, so a principal refused auto-creation can still create the same topic withCreateTopics. - 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 atmetadata.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- Its first failure is logged at
warn, and repeats within a minute atdebug.
- Its first failure is logged at
- 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 needsmanage_topicson
that stream, or globally, to create a topic in a stream that exists, and the globalmanage_streamsto create a missing stream. A principal withmanage_topicsbut 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 setsretention.mswithAlterConfigs. - 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
ownGatewayStatecallsshutdown_auto_topic_creationbefore closing the bridge. IGGY_KAFKA_AUTO_CREATE_TOPICS_PARTITIONSapplies to auto-creation only. ACreateTopicswithnum_partitions=-1still 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 answeredUNKNOWN_TOPIC_OR_PARTITION and a client waits until something else creates it.
Concurrency ceiling
- Each producer sends on an
IggyClientof 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
ofpartition.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 afterIGGY_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 aListOffsetslookup 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 retriableREQUEST_TIMED_OUT(7).
The Java admin client does not retry that code by itself:kafka-topics.shandAdmin.createPartitionsreport aTimeoutException, 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, orretries=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, orIGGY_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
genericResourceNotFoundthat 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/_PASSWORDare 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,TcpErrorandStaleClient→ 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 asTransientNotCommittedabove - 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, notINVALID_PARTITIONS- that code's
own text is "below 1", a different condition)- Too many partitions requested (
TooManyPartitions) →INVALID_PARTITIONS(37), reachable
throughensure_topic'spartition_countargument once it exceeds the server's cap - A create past the node's partition cap (
PartitionsLimitReached, 2022) →POLICY_VIOLATION
(44). Seemetadata.partitions_maxbelow - 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 answersHistoryUnavailable, 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. ACreatePartitions 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:
IGGY_MCP_IGGY_USERNAME=iggy IGGY_MCP_IGGY_PASSWORD=iggy cargo run --bin iggy-mcpUse 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).
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 = trueThe 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:
{
"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, ordelete MCP permissions. A process restart loses this local recovery state.
Systemd integration
Build with the systemd feature to enable systemd readiness and watchdog notifications:
cargo build --bin iggy-mcp --release --features iggy-mcp/systemdReadiness 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:
[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
- Support for multiple benchmark types:
- 🔍 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 identifiergitref: 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 identifierparams_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)
bashrustup default stableWebAssembly target
bashrustup target add wasm32-unknown-unknownTrunk (for frontend development)
bashcargo install trunk
Development Setup
Clone the repository:
bashgit clone <repository-url>Run the development script:
bash./scripts/dashboard/run_dev.shThis will start both the frontend development server and the backend server.
Running the Application
Production Mode
Build the release version:
bash./scripts/dashboard/build_release.shStart 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:
./scripts/dashboard/run_dev.shThis 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:
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:
mkdir -p performance_results
chmod 755 performance_resultsBasic usage (recommended):
docker run -p 8061:8061 \
-v "$(pwd)/performance_results:/data/performance_results" \
--user "$(id -u):$(id -g)" \
apache/iggy-bench-dashboardWith custom configuration:
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-dashboardUsing a named volume:
# 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-dashboardConfiguration
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:
- Use the
--userflag with your local user ID to ensure proper file permissions - Make sure your local directory has the correct permissions (755)
- If using a named volume, the container will handle permissions automatically
Application Configuration
Server Settings
The server can be configured using command-line arguments:
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 versionEnvironment Variables for Development
For development, you can also use environment variables:
RUST_LOG: Control log level and filtersRUST_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.
[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:
IGGY_CONNECTORS_CONFIG_PATH=connectors.toml cargo run --bin iggy-connectorsSupported 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}.statefor 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.
[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 = 2Everything 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_methodand remembers the returnedETag. Every write uses the configuredsave_methodand 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-Keyheader, 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 spurious412. - 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 (honoringRetry-After, capped atmax_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:
- The configured load method,
GETby default, at{url}/source_{key}returns200+ strongETag+ the stored bytes, or404if the key has never been written. If the value exists but cannot be served consistently yet, return425or503(optionally withRetry-After) - never404. - The configured save method,
PUTby default, at{url}/source_{key}must enforceIf-Match/If-None-Matchatomically against the stored version and return412on mismatch. Success responses (200/201/204) carry the newETag. Requests without a conditional header may be rejected with428 Precondition Required. 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.- ETags are strong and change on every committed write. The runtime treats them as opaque strings: stored verbatim, sent back verbatim.
- Authentication is the server's business, transported via the configured static headers; failures are
401/403. Treat403as 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.
[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.
[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.
[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.configordata.0). - response.error_path (optional): A path with the same syntax. Any non-null value at this path is treated as an error, including
falseor 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.
[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 theapi-keyin 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}/configsand then callingPOST .../restartis
enough to repoint a connector at a destination of the caller's choosing,
becauserestartre-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/activeandDELETE .../configssit behind the same key.A rewritten plugin
pathis not loaded by the restart.start_connector
reuses the containerdlopened 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_keyis empty by default, which means authentication is off by
default. Only/and/healthare exempt once it is set, so everything above
sits behind that one empty string, and the loopback defaultaddressis what
confines it to local processes.Three edits take that containment away:
Moving
addressoff loopback. Setapi_keyin 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 shippedallowed_origins = ["*"]. That
becomesAllowOrigin::any(), and the CORS layer wraps outside
authentication, so tower-http answers the preflight beforeresolve_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 configurationPOST
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 onallowed_methodsandcontent-typeonallowed_headers, both of which the shipped block grants.Setting
api_keycloses it, since an attacker's page cannot supply the
header. Pinningallowed_originsto origins the operator owns closes the
cross-origin read, and no startup warning fires for that case. One fires
fornull, which is narrower than*but not owned by anyone: a browser
sends it from a sandboxed iframe, adata:URL and afile://page, all of
which an attacker can produce, so pinning it buys nothing. No origin setting
closes the CORS-simple route below;api_keydoes.Leaving
http.tls.enabled = false. It ships disabled, so theapi-key
header and the credential-bearing responses both travel in cleartext. Enable
TLS alongsideapi_keywhenever this API leaves loopback. The runtime warns
at startup whenever the address resolves beyond loopback with TLS off, and
keeps warning afterapi_keyis 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 .../restartcarries no body and no
content type, which makes it a CORS-simple request: a page can issue it withmode: '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 settingallow_private_network = true
hands back the case Chrome would otherwise block.api_keyis 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,/sourcesor/sinksfor connector status.GET /stats: runtime statistics including process info, memory/CPU usage, and connector status.GET {http.metrics.endpoint}(default/metrics): Prometheus-formatted metrics, whenhttp.metrics.enabledistrue.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 - theversionquery 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 optionalformatquery 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 - theversionquery 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 optionalformatquery 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 underconfig_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:
[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:
benchmark = trueEmitted fields:
- sink:
connector_key,stream,topic,partition_id,current_offset,batch_size,processed_count,runs,decode_us,prepare_us,ffi_us,total_us.runsis the number ofconsume()calls the batch was split into, one per contiguous payload variant, andffi_usis 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_usis 0 whenstate_savedis 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 connectorsiggy_connectors_sources_running: Sources currently in Running statusiggy_connectors_sinks_total: Total configured sink connectorsiggy_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 pluginiggy_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 pluginiggy_connector_messages_filtered_total: Messages intentionally dropped by transforms returningOk(None)iggy_connector_errors_total: Errors encounterediggy_connector_sink_runs_total: FFIconsume()calls made for sink batches. A batch is split into one call per contiguous payload variant, so this counter overiggy_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
{
"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:
Source::poll()returns messages and candidate state without committing cursor changes or destructive operations.- The runtime sends the batch to Iggy and waits for the producer result.
- After a successful send, the runtime persists the candidate state if the batch provides it.
- The runtime reports
SourceBatchResult::Ackto the plugin. A send or state-save failure reportsSourceBatchResult::Nackinstead. 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:
[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:
cargo build --release -p iggy_connector_random_source -p iggy_connector_stdout_sink -p iggy-connectors -p iggy-cli
mkdir -p connectorsThe 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):
[iggy]
address = "localhost:8090"
username = "iggy"
password = "iggy"
[connectors]
config_type = "local"
config_dir = "connectors"Source connector config (connectors/protobuf_source.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 = 100Sink connector config (connectors/protobuf_sink.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 = falseCreate the stream and topic, then start the runtime from the repository root:
./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-connectorsThe 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.protofile containing message definitionsmessage_type: Fully qualified name of the protobuf message type to useuse_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 formatssource_format/target_format: Formats to convert between - any schema value (json,raw,text,proto,flat_buffer,avro). Onlyflatbuffer_convertcheckssource_formatagainst the payload it was handed and rejects a mismatch.proto_convertandavro_convertdispatch on the format pair with no up-front guard, which is why theschema = "raw"plussource_format = "proto"example above workspreserve_unknown_fields: Accepted byproto_convert, but currently has no effectinclude_paths: Additional directories searched for imported.protofilesfield_mappings: Renames fields in a JSON input object before conversion (e.g.,"old_field" = "new_field")conversion_options:pretty_jsoncontrols JSON text output andinclude_metadataenriches supported protobuf-to-JSON paths.validate_messages,type_url_prefix, andstrict_modeare 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.
[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_urland base64value. - Transforms:
proto_convertsupports 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 toflat_bufferoravrorewraps 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 agoogle.protobuf.BytesValue, insidegoogle.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:
mkdir -p connector-sdk-example/src schemasSave this as connector-sdk-example/Cargo.toml. The path dependency uses the SDK from the same checkout as the runtime:
[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:
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:
cargo run --manifest-path connector-sdk-example/Cargo.tomlDynamic Schema Loading
You can load or reload schemas programmatically:
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:
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:
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_pathordescriptor_set;schema_pathtakes 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;falsechanges the configuration while retaining the cached schema. - Fallbacks: Absent schema sources or an unmatched
message_typecan returnOk(())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 inPayload::Proto, not a schema-encoded binary message. With a descriptor it encodes a top-level JSON object toPayload::Rawand falls back toPayload::Protofor anything else, so one configured instance can return either variant depending on the message. - Two Schema Inverses:
Schema::Protomeans protobuf wire bytes when a source plugin setsProducedMessages::schema, and aPayload::Protostring when the runtime tags a sink batch fromPayload::schema().Schema::try_into_payloadreads the first andPayload::try_from_schemathe second. Sink plugins usePayload::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, anddeterministic_encodingare accepted but have no effect. Decoderpreserve_unknown_fieldsretains 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.
#[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.
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):
[connectors]
config_type = "local"
config_dir = "path/to/connectors"Sink connector config (connectors/stdout_sink.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:
IGGY_CONNECTORS_SINK_STDOUT_ENABLED=falseSample 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.
#[derive(Debug)]
struct State {
invocations_count: usize,
}#[derive(Debug)]
pub struct StdoutSink {
id: u32,
print_payload: bool,
state: Mutex<State>
}#[derive(Debug, Serialize, Deserialize)]
pub struct StdoutSinkConfig {
print_payload: Option<bool>,
}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.
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.
#[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).
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.
#[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.
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):
[connectors]
config_type = "local"
config_dir = "path/to/connectors"Source connector config (connectors/random_source_v0.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:
IGGY_CONNECTORS_SOURCE_RANDOM_ENABLED=falseSample 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.
#[derive(Debug)]
struct State {
current_id: usize,
}#[derive(Debug)]
pub struct RandomSource {
id: u32,
messages_count: u32,
state: Mutex<State>,
pending_state: Mutex<Option<State>>,
}#[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.
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.
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).
#[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.
/* 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.
iggy --username iggy --password iggy stream create example_stream
iggy --username iggy --password iggy topic create example_stream example_topic 1 none 1d --durability persistedAnd 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 addressIGGY_ROOT_USERNAME/IGGY_ROOT_PASSWORD- root credentialsIGGY_TCP_ADDRESS_LEADER/IGGY_TCP_ADDRESS_FOLLOWER- cluster addresses, leader redirection scenarios only
Run (recommended)
from the repository root run
./scripts/run-bdd-tests.sh csharpthe 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
IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
dotnet testTroubleshooting
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:
Linux kernel 5.19 or newer on the node
- Shard rings require
IORING_SETUP_COOP_TASKRUNandIORING_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.
- Shard rings require
IPC_LOCK capability - For locking memory required by io_uring
Unconfined seccomp profile - To allow io_uring syscalls
These are configured by default for the Iggy server via the chart's root-levelsecurityContext 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
# 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=secretpasswordNote:
server.serviceMonitor.enableddefaults tofalse.
Enable it only if Prometheus Operator is installed and you want aServiceMonitorresource.
The server still requires node/kernel support forio_uring, including on clean local clusters such askindorminikube.
Installation
From Git Repository
git clone https://github.com/apache/iggy.git
cd iggy
helm install iggy ./helm/charts/iggyWith Persistence
helm install iggy ./helm/charts/iggy \
--set server.persistence.enabled=true \
--set server.persistence.size=50GiWith Custom Values File
helm install iggy ./helm/charts/iggy -f custom-values.yamlIf Prometheus Operator is installed and you want monitoring, setserver.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:
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"
doneThe 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:
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 theRecreate 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:
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.nodeslist. OnlyselfReplicaIddiffers between releases. replicaIdvalues are unique and cover0..N-1for anN-node roster.ports.tcpReplicais 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 toserver.ports.server.cluster.nameis hashed into the on-disk cluster id on first boot.
Changing it later makes the server refuse to start against existing data.ipmust be a literal IP. Hostnames are rejected. UseadvertisedAddressfor 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:
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.rootmust 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.authmakes 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.encryptionencrypts 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 through0.9.0-edge.7, including its pinned image.
For newer builds with[encryption], setserver.encryption.configVersion=flat.
Custom tags require an explicitflatorlegacylayout;legacyuses[system.encryption].server.jwtmakes HTTP bearer tokens valid on every node and survive a
restart. Withcluster.authenabled 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 sizeserver.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 whilehelm --wait still reports success. The chart ships no HorizontalPodAutoscaler
for the same reason. Cluster size is a roster decision, so add a node toserver.cluster.nodes and install another release instead.
Uninstallation
helm uninstall iggyTesting
The chart CI paths are also available locally from the repository root.
Render Validation
If helm is already installed locally:
scripts/ci/test-helm.sh validateIf you want the pinned Linux CI tool version instead:
scripts/ci/setup-helm-tools.sh
scripts/ci/test-helm.sh validateThis 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_uringsupport 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-smokeremoves the Helm release and smoke namespace, but it does not delete the reusable kind cluster created byscripts/ci/setup-helm-smoke-cluster.sh
If helm and kind are already installed:
scripts/ci/setup-helm-smoke-cluster.sh
scripts/ci/test-helm.sh smoke --cleanupIf you want the pinned Linux CI tool versions:
scripts/ci/setup-helm-tools.sh --install-kind
scripts/ci/setup-helm-smoke-cluster.sh
scripts/ci/test-helm.sh smoke --cleanupIf a previous local smoke install failed and left resources behind, reset the smoke namespace with:
scripts/ci/test-helm.sh cleanup-smokeOn 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:
HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.shThe 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:
scripts/ci/test-helm.sh collect-smoke-diagnosticsNote:
scripts/ci/setup-helm-tools.shcurrently supports Linuxx86_64only.
On other local platforms, install equivalenthelmandkindbinaries 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 theio_uringsupport 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:
Cannot create runtime: Out of memory (os error 12)This means io_uring cannot lock sufficient memory. Ensure:
securityContext.capabilities.addincludesIPC_LOCKpodSecurityContext.seccompProfile.typeisUnconfined
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:
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 andpodSecurityContext.seccompProfile.type=Unconfined are set.
ServiceMonitor CRD not found
If you see:
no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"Either install Prometheus Operator or disable ServiceMonitor:
helm install iggy ./helm/charts/iggy --set server.serviceMonitor.enabled=falseServer 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:3000IGGY_TCP_ADDRESS=0.0.0.0:8090IGGY_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 setsserver.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 theserver.env entry would take effect.
Accessing the Server
Port Forward
# HTTP API
kubectl port-forward svc/iggy 3000:3000
# Web UI
kubectl port-forward svc/iggy-ui 3050:3050Using Ingress
Enable ingress in values. Set className and any controller-specific annotations to match your
ingress implementation:
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
# 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 validatePre-commit Hooks
These tools are integrated with pre-commit. Install hooks with:
pre-commit installValues
| 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
AppendRowson the table's_defaultstream. No legacyinsertAll. - Two modes:
mappedmaps top-level JSON fields to table columns by name.rawstores the whole payload in one JSON, STRING or BYTES column. - Schema validation at startup: the table schema is read with
tables.getand checked before any data flows. Unsupported column types failopen(). - 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
AppendRowslimit. - Retries: transient gRPC failures are retried with jittered exponential backoff.
Configuration
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 = trueConfiguration 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:
emulator_endpointandemulator_grpc_endpoint: no credentials, plain-text connections. For tests only. Combining emulator endpoint overrides with either credential option is rejected.credentials_path: a service account key file.credentials_json: an inline service account key.- Application Default Credentials:
GOOGLE_APPLICATION_CREDENTIALS,gcloud auth application-default login, or the metadata server on GCE, GKE and Cloud Run.
# 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,TextandPrototext are accepted when they hold a JSON object.Raw,FlatBufferandAvropayloads 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
nullfor 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
nullare 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_EXHAUSTEDandUNAUTHENTICATEDare retried up tomax_retriesattempts 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_ARGUMENTandPERMISSION_DENIED, fails the request without a retry. tables.getretries HTTP 429, 5xx and transport errors. A 403 or 404 failsopen()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
_defaultstream has no append offsets. When an append result is lost and the request is retried, rows can be written twice. Deduplicate downstream oniggy_stream,iggy_topic,iggy_partition_idandiggy_offset, for example:
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 = 1Schema 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
Create a dataset and table (requires
gcloudandbq):bashexport 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 )"Build the binaries and the plugin, and write a runtime config that loads only this connector:
bashcargo 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 EOFStart the server, create the stream, then start the runtime:
bashIGGY_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-connectorsProduce 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"}'Check the rows, and look for the
dropped messagewarning for the second message in the runtime log:bashbq --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_binarymode, 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
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 = falseConfiguration 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.
[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.
[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.
[plugin_config]
url = "http://localhost:8123"
table = "events"
insert_format = "string"
string_format = "csv" # or "tsv" or "json_each_row"Example Configs
JSON Events
[[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
[[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 = trueCSV Passthrough
[[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:
- Explicit column list in the INSERT. Emitting
INSERT INTO db.table (col1, col2, ...) FORMAT RowBinaryWithDefaultsbinds the stream to column names instead of table position. ClickHouse then routes each value by name, appliesDEFAULTfor 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. - Refresh the schema on a failed insert. When an insert fails with a data error, re-fetch the schema from
system.columnsand 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:
ReplicatedMergeTreeenables a deduplication log by default, controlled byreplicated_deduplication_windowandreplicated_deduplication_window_seconds.- Non-replicated
MergeTreecan also deduplicate whennon_replicated_deduplication_windowis 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:
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:
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_lengthandpoll_intervalhave to be carefully set for Delta tables.poll_intervalsets the minimum gap between polls. Each poll returns immediately with whatever messages are currently available, up tobatch_length. The Delta sink writes and commits exactly what one poll returns, so each Delta commit holds at mostbatch_lengthrows, and topic intake is capped at roughlybatch_lengthmessages perpoll_intervalper topic.batch_lengthis 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 highbatch_lengthis set. For Delta, writing means creating a log entry and a separate Parquet file, so a lowbatch_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 applyOPTIMIZEquery in order to consolidate the files. We recommend settingbatch_lengthpretty 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 ideallybatch_lengthshould 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 largerbatch_lengthmeans 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 thefile://scheme. - storage_backend_type (optional): The cloud storage backend to use. One of
"s3","azure", or"gcs". Omit for local filesystem tables.
Local filesystem
[plugin_config]
table_uri = "file:///tmp/iggy_delta_table"AWS S3
Currently the implementation offers two possible ways of accessing the bucket.
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-connectorsprocess 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.hcldata "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"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. SetAWS_FORCE_CREDENTIAL_LOAD=truein theiggy-connectorsprocess environment to restore the full chain. - aws_s3_allow_http: Optional. Set to
trueto allow HTTP connections (for local development).
Azure Blob Storage
[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
[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 intable_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.
databaseandtableconfig values must match[A-Za-z0-9_]+. Anything else is rejected at startup withError::InvalidConfigValue— this also prevents path traversal in the constructed/api/{db}/{table}/_stream_loadURL.- Messages must arrive with
Payload::Json(i.e. the configured stream schema isjson). If a non-JSON payload reaches the connector it logs aterror!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. (Underschema = "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::Protois the exception: aproto_converttransform 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
columnsplugin setting for field mappings or derived expressions.
How it works
- 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). - It computes a deterministic Stream Load
labelof the form{label_prefix}-{stream_san}-{topic_san}-{hash16}-{partition}-{first_offset}-{last_offset}.hash16is 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.v1vsevents_v1) or in two tenants' prefixes that truncate alike (prod_events_us_east_1vs..._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
2xxwith a missing or unreadable body) is absorbed, not doubled.
The runtime uses consumer auto-commit beforeconsume()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.
- It
PUTs the batch to{fe_url}/api/{database}/{table}/_stream_loadwith HTTP Basic auth,Expect: 100-continue,label: <label>, and the format headers (format: json+strip_outer_array: truefor JSON;format: csvwith control-charcolumn_separator/line_delimiter+enclose/escapefor CSV).Expect: 100-continueis required by Doris's Stream Load endpoint, which rejects PUTs that omit it; it also lets Doris reject auth/4xx before the body uploads. - The Doris frontend (FE) responds with a
307 Temporary Redirectto a backend (BE). The connector follows the redirect manually so that theAuthorizationheader is preserved across the hop (reqwest's default policy strips it on cross-host redirects).308 Permanent Redirectis also followed as a defensive measure; redirects beyond a hard cap of 5 (or a redirect with no usableLocation) are rejected as a permanentPermanentHttpError, since retrying a malformed/looping redirect cannot help. - The HTTP body is parsed as JSON and the
Statusfield decides the outcome:Success→ batch accepted.Label Already ExistswithExistingJobStatus: FINISHED→ idempotent replay, treated as success.RUNNINGorCANCELLEDis 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
2xxresponse 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 tomax_retriesattempts (exponential backoff + jitter) under the same label before being surfaced. Fail, any other4xx, or a non-empty unparsable response body → permanent error (Error::PermanentHttpError); never retried — re-PUTing bad data would just hammer the FE.
- A transient failure (the classifications above, plus a transport-level error) is retried in-request: the same batch is re-
PUTunder the same label, up tomax_retriesattempts 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 |