This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iggy-website.git
The following commit(s) were added to refs/heads/main by this push:
new 628d424bb blog: add post about Iggy 0.9.0 release (#112)
628d424bb is described below
commit 628d424bbd42c7ae0bc77753efc9c90a18264202
Author: Piotr Gankiewicz <[email protected]>
AuthorDate: Mon Sep 21 11:16:18 2026 +0200
blog: add post about Iggy 0.9.0 release (#112)
Co-authored-by: hubcio <[email protected]>
---
content/blog/release-0.9.0.mdx | 542 +++++++++++++++++++++++++++++++++++++
scripts/diagram-text.mjs | 3 +
scripts/generate-docs-markdown.mjs | 2 +-
src/app/global.css | 1 +
src/components/bench-0-9-0.tsx | 461 +++++++++++++++++++++++++++++++
src/mdx-components.tsx | 3 +
6 files changed, 1011 insertions(+), 1 deletion(-)
diff --git a/content/blog/release-0.9.0.mdx b/content/blog/release-0.9.0.mdx
new file mode 100644
index 000000000..927ee1662
--- /dev/null
+++ b/content/blog/release-0.9.0.mdx
@@ -0,0 +1,542 @@
+---
+title: Apache Iggy 0.9.0 Release
+description: Apache Iggy 0.9.0 brings VSR clustering, per-topic durability,
SDK and connector updates, and a first look at performance work for the next
release.
+author: piotr, hubert
+tags: ["release", "0.9.0", "announcement", "clustering", "vsr", "benchmarks"]
+date: 2026-09-21
+---
+## Release 0.9.0
+
+Five months after
[0.8.0](https://iggy.apache.org/blogs/2026/04/22/release-0.8.0/), we're proud
to release **[Apache Iggy 0.9.0](/downloads/)**. It is our first release as an
**Apache Top-Level Project**, following the [graduation from the
Incubator](https://iggy.apache.org/blogs/2026/08/24/apache-iggy-top-level-project-tlp-graduation/)
on August 19, 2026, and the first one without the `-incubating` suffix. It is
also the release we have been working towards for the past year.
+
+The official release is the **[source
archive](https://www.apache.org/dyn/closer.lua/iggy/0.9.0/iggy-0.9.0-src.tar.gz)**
distributed by the Apache Software Foundation, with its [PGP
signature](https://downloads.apache.org/iggy/0.9.0/iggy-0.9.0-src.tar.gz.asc),
[SHA-512
checksum](https://downloads.apache.org/iggy/0.9.0/iggy-0.9.0-src.tar.gz.sha512)
and the project [KEYS](https://downloads.apache.org/iggy/KEYS). The [downloads
page](/downloads/) explains how to verify it. The [GitHub relea [...]
+
+**Iggy now runs as a cluster.** 0.9.0 ships **Viewstamped Replication
Revisited (VSR)** consensus in the standard `iggy-server` binary. Start three
nodes, lose one, and the cluster keeps serving reads and writes. There is no
separate build, feature flag, or clustered edition. The same server runs
single-node or replicated, and `cluster.enabled` in the configuration decides
which one you get.
+
+This is the biggest release in the project's history: **over 600 merged pull
requests from 60 contributors, 34 of them contributing to Iggy for the first
time.** Alongside the server, we're releasing the Rust SDK 0.11.0, the CLI
0.14.0, the connectors runtime 0.5.0, the MCP server 0.5.0, the Web UI 0.4.0,
the bench dashboard 0.8.0, and the Python 0.9.0, Node.js 0.10.0, Java 0.9.0, C#
0.9.0 and Go 0.9.0 SDKs, all from the same repository. Beyond clustering, 0.9.0
brings **per-topic durabi [...]
+
+Before the changelog, two topics deserve their own space: **clustering** and
**performance**.
+
+---
+
+## Clustering with VSR consensus
+
+For three releases the roadmap section of these posts has ended the same way:
complete the clustering implementation with VSR consensus. 0.7.0 laid the
consensus foundations. 0.8.0 added the shard crate, the plane abstractions, the
persistent WAL journal, and the experimental `iggy-server-ng` binary. In 0.9.0
that work became the server. **[server-ng was promoted to the Apache Iggy
server](https://github.com/apache/iggy/pull/3856)**, the `vsr` feature flag is
gone, the legacy server and [...]
+
+### What a cluster gives you
+
+Apache Iggy replicates with **[Viewstamped Replication
Revisited](https://github.com/apache/iggy/blob/master/assets/vsr.pdf)**. VSR
keeps an ordered state machine consistent across replicas and elects a new
primary when the current one fails:
+
+- **Normal operation**: the primary assigns an operation number, sends
`Prepare`, and commits after a quorum answers `PrepareOk`.
+- **View change**: when replicas stop hearing from the primary, they exchange
`StartViewChange` and `DoViewChange`, and the new primary sends `StartView`.
Failover needs no operator.
+- **Recovery**: a restarted or lagging replica probes the current view and
repairs the missing WAL ranges from its peers before it serves current state.
+
+**Three replicas tolerate one unavailable replica, five tolerate two.** Three
nodes is the recommended minimum for a highly available deployment. A two-node
cluster is useful for development, but it cannot make progress after either
node fails.
+
+Replication is split by namespace rather than run as one global log:
+
+| Plane | Consensus group | Replicated state |
+| --- | --- | --- |
+| Metadata | One group on shard 0 | Streams, topics, users, permissions,
consumer groups, access tokens |
+| Partition | **One group per partition** | Messages and consumer offsets |
+
+Each group runs its own consensus and elects its own primary. Metadata writes
route through the metadata primary, and partition writes route through the
primary of that partition. Reads are served from the local replicated state on
any node, followers included.
+
+### How it was built
+
+The consensus work spans well over a hundred pull requests. The milestones, in
roughly the order they landed:
+
+- **[Consensus per partition](https://github.com/apache/iggy/pull/3071)** and
the **[consensus group mechanism](https://github.com/apache/iggy/pull/3138)**
made every partition its own replicated state machine
+- **[Session registration with combined login and
register](https://github.com/apache/iggy/pull/3108)**, later **[reworked so
sessions resume across restarts](https://github.com/apache/iggy/pull/3753)**,
backed by the client table for at-most-once request handling
+- **[Async fire-and-forget message
bus](https://github.com/apache/iggy/pull/3134)** for consensus traffic, plus
**[QUIC, TCP-TLS, WebSocket and WSS
transports](https://github.com/apache/iggy/pull/3192)** for clients
+- **[Replica bootstrap](https://github.com/apache/iggy/pull/3163)** and
**[multi-shard cross-shard
communication](https://github.com/apache/iggy/pull/3269)**, so all shards of a
node take part in consensus
+- **[Partition reconciliation
loop](https://github.com/apache/iggy/pull/3436)**, **[partition background
tasks](https://github.com/apache/iggy/pull/3564)**, and **[replica rejoin via
view probe and message repair](https://github.com/apache/iggy/pull/3667)**
+- **[Metadata VSR state persisted in a durable
superblock](https://github.com/apache/iggy/pull/3767)**, **[state transfer for
the metadata snapshot and client
table](https://github.com/apache/iggy/pull/3765)**, and **[state transfer for
partitions](https://github.com/apache/iggy/pull/3808)**, which let a wiped
replica refill itself from its peers
+- **[Committed sends confirmed with partition and
offset](https://github.com/apache/iggy/pull/3781)**, so producers learn exactly
where their batch landed
+- **[Deduplication of partition writes with per-group client table
slices](https://github.com/apache/iggy/pull/3959)**, so a produce retried after
a timeout does not write twice
+- **[Follower HTTP requests forwarded to the
primary](https://github.com/apache/iggy/pull/3744)**, so any node can answer
any control-plane request
+- **[Inbox split into consensus and client-reply
lanes](https://github.com/apache/iggy/pull/3915)**, so a burst of client
traffic cannot starve replication
+- **[Metadata reads served at or above the client's own
writes](https://github.com/apache/iggy/pull/4024)**
+
+Then came the correctness fixes found by the simulator, the crash tests and
real cluster runs:
+
+- **[View change no longer discards committed
ops](https://github.com/apache/iggy/pull/3824)**
+- **[A replica is kept off a hole in its committed
prefix](https://github.com/apache/iggy/pull/4073)**
+- **[Repaired prepares can't rewind the WAL
parent](https://github.com/apache/iggy/pull/3978)**
+- **[The dedup fence survives capacity
eviction](https://github.com/apache/iggy/pull/3934)**
+- **[Writes recover after a fast primary
rejoin](https://github.com/apache/iggy/pull/3987)**
+- **[A late partition materialiser can't wedge its
group](https://github.com/apache/iggy/pull/4007)**
+- **[Partition and metadata repair are driven from the
tick](https://github.com/apache/iggy/pull/4006)**
+- **[An unservable header counts as a
nack](https://github.com/apache/iggy/pull/4109)**
+- **[Frames no plane claims are
refused](https://github.com/apache/iggy/pull/4103)**
+- **[Torn segment tails are truncated instead of
resurrected](https://github.com/apache/iggy/pull/3946)**
+- **[Spurious heartbeat elections are
prevented](https://github.com/apache/iggy/pull/4194)**, the last change before
the tag
+
+### Durability you choose per topic
+
+Replication forced a precise answer to a simple question: what does an
acknowledgement mean? 0.9.0 answers it with two create-only topic options,
introduced in **[let topics require durable
acks](https://github.com/apache/iggy/pull/4092)**:
+
+| Policy | Required before success |
+| --- | --- |
+| `replicated` (default) | VSR quorum commit and local application, without an
additional stable-storage barrier |
+| `persisted` | VSR quorum commit backed by recoverable stable-storage copies
at the required quorum |
+
+`durability` governs message production and `consumer_offset_durability`
governs explicit offset stores and deletes. They default independently and
neither inherits the other. Both policies write data to disk. With `persisted`,
each multi-replica partition keeps a bounded on-disk prepare WAL, sized by
`partition.wal_bytes_max` (256 MiB by default), and a replica cannot release
its `PrepareOk` until its message bodies, WAL history and durable frontier are
recoverable. The old `enforce_fsy [...]
+
+The details are in [Durability](/docs/server/durability) and [Cluster
Durability](/docs/clustering/durability).
+
+### Secure replica traffic
+
+The replica-to-replica port is authenticated and, optionally, encrypted:
+
+- **[PSK + BLAKE3 handshake](https://github.com/apache/iggy/pull/3425)**:
every peer proves possession of a cluster-wide pre-shared key of at least 32
bytes through a three-message keyed-MAC handshake before it can register as a
replica. A `previous_shared_secret` enables rolling key rotation without an
authentication outage.
+- **[TLS 1.3 on the replica plane with PSK channel
binding](https://github.com/apache/iggy/pull/3461)**: the opt-in
`[cluster.tls]` table wraps replica connections in TLS 1.3 (ALPN
`iggy-replica`) with CA and self-signed modes. The PSK MAC absorbs the TLS
exporter value, so a relay that terminates both legs fails the handshake.
+- **[Cluster metadata is auth-gated and registration is forwarded to the
primary](https://github.com/apache/iggy/pull/3872)**: unauthenticated clients
can no longer read the roster, and a client that dials a backup completes its
login there. Credentials never cross the replica interconnect.
+- **[Incompatible clients are rejected at login via protocol
version](https://github.com/apache/iggy/pull/3454)**
+
+See [Cluster Security](/docs/clustering/security).
+
+### Client failover
+
+Every SDK speaks the VSR wire protocol and can connect to any node.
Leader-aware clients fetch cluster metadata after login, reconnect to the
advertised leader when they landed on a follower, poll for a new leader through
an election, and cap the number of redirects so a flapping roster cannot bounce
them forever:
+
+- Rust: **[fail over to a surviving node when the current one
dies](https://github.com/apache/iggy/pull/3944)**, **[rejoin consumer groups
after membership loss](https://github.com/apache/iggy/pull/3703)**,
**[heartbeat handle](https://github.com/apache/iggy/pull/3852)**
+- Java: **[cluster metadata and leader redirection in the TCP
client](https://github.com/apache/iggy/pull/3745)**
+- C#: **[leader redirection BDD
scenario](https://github.com/apache/iggy/pull/3193)**
+- Rust, Go, C# and Java run the shared `leader_redirection` BDD suite against
a real cluster
+
+Writes return after the required VSR commit, or return a **retryable** error
while a replica is changing view or catching up. `TransientNotAccepted` proves
the request was never admitted and permits a retry on another node.
`TransientNotCommitted` has an uncertain outcome and is replayed with the same
session and request identity, and the server-side dedup takes care of the rest.
Details in [Client Failover](/docs/clustering/client-failover).
+
+Per-node **[advertised addresses](https://github.com/apache/iggy/pull/3757)**
and **[per-client-network address
selectors](https://github.com/apache/iggy/pull/3818)** let a node publish
different endpoints to clients on different networks, which matters for
containers and cloud deployments.
+
+### Running a cluster
+
+Every node loads the same configuration and is told apart only by
`--replica-id`. Save this configuration as `iggy-vsr.toml`:
+
+```toml
+[cluster]
+enabled = true
+name = "iggy-vsr-dev"
+
+[cluster.auth]
+enabled = true
+shared_secret = ""
+
+[[cluster.nodes]]
+name = "iggy-node-1"
+ip = "127.0.0.1"
+replica_id = 0
+ports = { tcp = 8090, quic = 8080, http = 3000, websocket = 8093, tcp_replica
= 9090 }
+
+[[cluster.nodes]]
+name = "iggy-node-2"
+ip = "127.0.0.1"
+replica_id = 1
+ports = { tcp = 8091, quic = 8081, http = 3001, websocket = 8094, tcp_replica
= 9091 }
+
+[[cluster.nodes]]
+name = "iggy-node-3"
+ip = "127.0.0.1"
+replica_id = 2
+ports = { tcp = 8092, quic = 8082, http = 3002, websocket = 8095, tcp_replica
= 9092 }
+```
+
+Set the shared configuration path, root credentials and replica authentication
secret in each terminal. Use the same credentials and secret for all three
nodes. Root credentials are required on the first boot, and the secret must
contain at least 32 bytes:
+
+```bash
+export IGGY_CONFIG_PATH="$PWD/iggy-vsr.toml"
+export IGGY_ROOT_USERNAME=iggy
+export IGGY_ROOT_PASSWORD="replace-with-your-root-password"
+export IGGY_CLUSTER_AUTH_SHARED_SECRET="replace-with-at-least-32-random-bytes"
+```
+
+Start each node in a separate terminal:
+
+```bash
+IGGY_PATH=local_data/node-0 iggy-server --replica-id 0
+IGGY_PATH=local_data/node-1 iggy-server --replica-id 1
+IGGY_PATH=local_data/node-2 iggy-server --replica-id 2
+```
+
+Every roster field can also be set through typed `IGGY_CLUSTER_*` environment
variables, which is what the new **[Helm chart cluster
mode](https://github.com/apache/iggy/pull/4035)** renders. The **[Deploy a
Cluster](/docs/clustering/deploy)** guide walks through the whole setup, and
the **[Configuration](/docs/clustering/configuration)** reference covers every
`[cluster]` key, including consensus timing (`heartbeat_timeout`, view change
and repair intervals, all on a fixed 10ms tick).
+
+### Testing a consensus protocol
+
+We trust this implementation because of how much effort went into breaking it.
Most of that effort is deterministic simulation:
+
+- **[Seed-based workload fuzz
harness](https://github.com/apache/iggy/pull/3272)** and the **[workload fuzzer
with consensus oracles](https://github.com/apache/iggy/pull/3591)**:
randomized, fully seed-deterministic workloads run against a predictive shadow,
crash replicas, and assert consensus invariants on every tick and at quiesce.
Every failure reproduces from its seed.
+- **[Deterministic future executor and server dispatch
shell](https://github.com/apache/iggy/pull/3671)**: the real server dispatch
code runs inside the simulator
+- **[Workloads under network and replica
faults](https://github.com/apache/iggy/pull/3942)**: message loss, delays,
partitions, crashes with stability windows, client resends with target
rotation. This one alone found two real bugs before any user could.
+- **[Durability failures no existing test could
reach](https://github.com/apache/iggy/pull/4132)**: a storage model injects
crashes, power loss and torn writes into the WAL tests
+- **[SIGKILL crash-durability cluster
tests](https://github.com/apache/iggy/pull/3905)** and **[client table
durability across node restart](https://github.com/apache/iggy/pull/3736)** on
real processes
+- **[On-disk storage format breaks caught
pre-merge](https://github.com/apache/iggy/pull/4003)**: a baseline data
directory boots against every pull request
+- **[Miri undefined behavior
detection](https://github.com/apache/iggy/pull/3284)** for the
`binary_protocol` and `consensus` crates
+- Cross-SDK BDD suites running against a real two-node cluster
+
+The [Viewstamped Replication](/docs/clustering/vsr) page is the entry point to
all of this.
+
+---
+
+## Performance
+
+Efficiency has always been the point of Iggy: thread-per-core, shared-nothing,
`io_uring`, and no copies where the format allows it. 0.9.0 carried that into
the replicated server and shipped a series of hot-path improvements:
+
+- **[Poll replies served without copying record
bytes](https://github.com/apache/iggy/pull/4025)**: a poll reply used to be
three copies plus a memset, roughly four times the payload in memory traffic.
It is now a 272-byte head fragment followed by the journal or disk fragments as
they are, written with a single `writev`.
+- **[Persisted-mode disk traffic per acknowledgment
cut](https://github.com/apache/iggy/pull/4146)**: publishing the WAL frontier
took three barriers on three inodes per batch. It is now two slots in one file,
overwritten in place, and the body and WAL barriers land in one journal window.
+- **[Log and index persisted
concurrently](https://github.com/apache/iggy/pull/3970)**: the two serialized
`fdatasync` calls per flush became one `join`. On the fsync-gated benchmark
used in that pull request, throughput went from 13.4k to 26.1k msg/s and p50
from 5.83 to 3.01 ms.
+- **[Batch checksum computed in a single produce
pass](https://github.com/apache/iggy/pull/3746)**, **[user headers decoded
without copying the blob](https://github.com/apache/iggy/pull/3708)**,
**[message header written directly into the target
buffer](https://github.com/apache/iggy/pull/3707)**
+- **[Sealed-segment read handles capped with a per-partition
LRU](https://github.com/apache/iggy/pull/3806)**, and the active segment no
longer pays one `openat` per poll
+- **[16 MiB per active segment index no longer
reserved](https://github.com/apache/iggy/pull/3974)**, **[consumer offset state
bounded per partition](https://github.com/apache/iggy/pull/4063)**
+- **[Unnecessary backpressure under concurrent clients
eliminated](https://github.com/apache/iggy/pull/3692)**, **[produce and poll
overhead reduced in cluster mode](https://github.com/apache/iggy/pull/4169)**
+- **[TCP-TLS and WSS connections distributed across
shards](https://github.com/apache/iggy/pull/4193)**
+- **[mimalloc restored as the global
allocator](https://github.com/apache/iggy/pull/3971)**
+- **[CPU affinity and cgroup limits
respected](https://github.com/apache/iggy/pull/3615)** in shards and stats, so
a container gets the shard count it was given
+- Foreign SDKs **[generate message ids from random bytes instead of
UUIDs](https://github.com/apache/iggy/pull/4076)**, and the Go SDK **[pools its
request-path wire payload](https://github.com/apache/iggy/pull/3442)**
+
+### Benchmarks: 0.9.0 and what comes next
+
+We're not done. A dedicated performance effort started right after the 0.9.0
code freeze, and the first results are in. The tables below compare **the 0.9.0
release against a development snapshot**, both measured with `iggy-bench` on
the same host with identical workload parameters. Each version ran its own
build of `iggy-bench`, because the development client sends the new deferred
poll. These results therefore compare the server and client together. Each cell
reads `0.9.0 → next`, wher [...]
+
+**Durability legend**
+
+- **Replicated** (`durability=replicated`, the default for producer rows
without a `persisted` label): a successful write waits for quorum commit and
local application, but does not wait for stable storage. Acknowledged data can
still be in memory.
+- **Persisted** (`durability=persisted`): a successful write also requires
recoverable copies on stable storage at the replication quorum. On a three-node
cluster, that means at least two replicas.
+
+Both policies write data to disk. On a single node, the quorum is one:
*replicated* provides no extra copy, while *persisted* waits for local stable
storage. These labels describe producer acknowledgements. Consumer rows measure
reads. See [Durability](/docs/server/durability) for the full guarantees.
+
+**Workload legend**
+
+The comparisons below cover rate-limited workloads.
+
+- **Pinned producer / consumer**: each actor owns its own stream and
partition. *Actors* counts concurrent producers or consumers.
+- **800 MB/s / 500 MB/s**: the configured aggregate rate limit. *Achieved
MB/s* is the measured throughput, which can be lower. Compare latency alongside
achieved throughput.
+- **Cold / warm**: consumers read uncached data or re-read cached data,
respectively.
+
+<Bench090DurabilityChart />
+
+<Bench090LatencyChart />
+
+<details className="my-6 rounded-xl border border-fd-border px-4 sm:px-6">
+<summary className="cursor-pointer py-4 font-semibold text-fd-foreground">Full
benchmark results: 0.9.0 → next version</summary>
+<div className="min-w-0 overflow-x-auto pb-4 [&_td]:whitespace-nowrap
[&_th]:whitespace-nowrap">
+
+#### Single node: 0.9.0 → next version
+
+Each cell shows **0.9.0 → next version with optimizations**. The next-version
results are from a development snapshot.
+
+| Workload (configured rate limit) | Achieved MB/s | p50 ms | p99 ms | p999 ms
|
+| --- | ---: | ---: | ---: | ---: |
+| pinned producer, 20 actors, 800 MB/s | 800.1 → 800.1 | 0.650 → 0.588 | 1.313
→ 1.145 | 11.087 → 2.956 |
+| pinned producer, 20 actors, persisted, 800 MB/s | 800.0 → 800.1 | 1.232 →
1.098 | 1.601 → 1.424 | 2.249 → 1.967 |
+| pinned consumer, 20 actors, cold, 800 MB/s | 800.1 → 800.1 | 0.498 → 0.447 |
4.698 → 3.313 | 5.156 → 3.777 |
+| pinned consumer, 20 actors, warm, 800 MB/s | 800.1 → 800.1 | 0.521 → 0.544 |
1.022 → 0.923 | 1.573 → 3.920 |
+| pinned producer, 1 actor, 500 MB/s | 500.0 → 500.0 | 0.347 → 0.352 | 0.679 →
0.683 | 1.356 → 1.046 |
+| pinned consumer, 1 actor, warm, 500 MB/s | 500.0 → 500.0 | 0.272 → 0.296 |
0.468 → 0.478 | 0.491 → 0.502 |
+| pinned producer, 1 actor, persisted, 500 MB/s | 372.1 → 335.8 | 0.665 →
0.733 | 0.825 → 0.901 | 0.995 → 1.151 |
+
+#### 3-node cluster: 0.9.0 → next version
+
+Each cell shows **0.9.0 → next version with optimizations**. The next-version
results are from a development snapshot.
+
+| Workload (configured rate limit) | Achieved MB/s | p50 ms | p99 ms | p999 ms
|
+| --- | ---: | ---: | ---: | ---: |
+| pinned producer, 20 actors, persisted, 800 MB/s | 800.0 → 800.0 | 2.905 →
2.243 | 7.073 → 2.938 | 12.403 → 3.889 |
+| pinned producer, 1 actor, persisted, 500 MB/s | 234.4 → 233.6 | 1.047 →
1.062 | 1.200 → 1.223 | 10.857 → 1.759 |
+| pinned consumer, 20 actors, cold, 800 MB/s | 800.1 → 800.0 | 0.477 → 0.436 |
3.831 → 2.346 | 4.241 → 2.675 |
+| pinned consumer, 20 actors, warm, 800 MB/s | 800.1 → 797.9 | 0.484 → 0.451 |
0.933 → 0.812 | 1.233 → 1.216 |
+| pinned producer, 20 actors, 800 MB/s | 800.0 → 800.0 | 1.782 → 1.679 | 3.186
→ 3.250 | 3.683 → 3.660 |
+| pinned producer, 1 actor, 500 MB/s | 337.2 → 325.4 | 0.716 → 0.735 | 0.973 →
1.016 | 1.057 → 1.130 |
+| pinned consumer, 1 actor, warm, 500 MB/s | 500.0 → 500.3 | 0.297 → 0.326 |
0.493 → 0.536 | 0.533 → 0.574 |
+
+The single-node warm consumer with 20 actors saw p999 rise from 1.6 ms to 3.9
ms. Some single-actor rows also lost throughput or added latency. **These
numbers are a snapshot of work in progress, not a final result.** The branch is
being profiled and tuned as we write this, with further changes aimed at
throughput and tail latency.
+
+</div>
+</details>
+
+The tails are where the work shows. On a three-node cluster with `persisted`
topics, the **p99 of a 20-producer workload dropped from 7.07 ms to 2.94 ms and
the p999 from 12.4 ms to 3.9 ms**. The single-producer p999 on the same cluster
went from 10.9 ms to 1.8 ms. On a single node, the 20-producer **p999 fell from
11.1 ms to 3.0 ms**. Cold consumer reads improved on both setups. In this
development snapshot, 20 persisted producers on a three-node cluster achieved
800 MB/s with a median [...]
+
+### Three changes behind the numbers
+
+Three changes are in flight, and the tables above measure them together
against 0.9.0. These runs do not isolate how much each change contributes. A
fourth is on the drawing board.
+
+#### Consumers stop asking for nothing
+
+A polling consumer pays a round trip for nothing on an idle topic, and on a
busy one its interval trades log latency against empty replies.
+
+`pollMessagesDeferred` (commands 105 and 106, tracked in
[#3470](https://github.com/apache/iggy/issues/3470)) appends a 24-byte trailer
to the poll request: a maximum wait, a minimum message count, a byte cap for
the reply, and a total request budget. The server parks the request and answers
when the minimum is readable, the cap is reached, or the wait expires. The Rust
client speaks it on every transport, the Java client over TCP and HTTP, and the
Rust consumer now defaults to bounded l [...]
+
+#### File I/O leaves the shard loop
+
+A shard owns many partitions and used to wait for each one's file writes and
durability barriers before serving the next, so one slow disk delayed every
partition on the shard.
+
+Now each file job runs as its own task, up to 16 in flight by default, and its
result returns to the shard for identity matching and ordered publication.
Storage work now overlaps with network work.
+
+#### Replica links read ahead
+
+Every pair of nodes shares one TCP connection for consensus traffic, owned by
a single link shard. Reading one frame took two `io_uring` reads, one for the
256-byte header and one for the body, and frames already in the kernel receive
queue waited for the next call. Under load that shard bottlenecked the cluster
write path.
+
+A 256 KiB read-ahead buffer
([#4224](https://github.com/apache/iggy/pull/4224)) now serves a whole burst
with one socket read. A frame at least as large as the buffer skips it and
lands in its own allocation, so large messages pay no extra copy.
+
+#### The next one: more replica links
+
+The read-ahead makes each read cheaper, but every partition group a pair of
nodes replicates still goes through one link shard.
+
+The next change gives a peer several links, spread over shards. An operator
picks the link count per peer, each link carries a contiguous range of shards,
and the default stays at today's one. The upgrade rolls, but changing the link
count takes a coordinated restart, and nodes that disagree on it refuse each
other.
+
+This one is a design, not code, and the read-ahead has to justify it first.
More links only make sense when the link shard stays saturated behind the
buffer. Otherwise the cheaper fix has already won.
+
+All three measured changes are still in development, and every number above
will move before they ship. A driver for the OpenMessaging Benchmark is in
progress as well, and its results will follow.
+
+Beyond these, in the order we expect them to land:
+
+- **Explicit group commit**: several acknowledgements share one disk barrier
by design, rather than by whatever happened to queue up during the previous one
+- **Direct I/O** for the storage engine, writing past the page cache instead
of paying for `fsync` barriers
+- **Multi-leader clusters**, further out: a leader per partition, with leaders
spread across all nodes of the cluster, so write traffic and replication work
are shared by every machine instead of concentrating on one
+
+And a long tail of smaller changes in the same direction. Efficiency is the
reason Iggy exists, and it stays the priority now that clustering is in.
+
+A like-for-like comparison of 0.8.0 against 0.9.0 with this parameter set is
planned for the [benchmarking platform](https://benchmarks.iggy.apache.org),
together with the final numbers for the next release. The [benchmarking
guide](/docs/server/benchmarking) has the commands to reproduce these workloads
on your own hardware, and the [Linux tuning guide](/docs/server/linux-tuning)
covers the host preparation.
+
+---
+
+## Iggy Server
+
+### One server, one wire protocol
+
+- **[server-ng promoted to the Apache Iggy
server](https://github.com/apache/iggy/pull/3856)**: the thread-per-core,
`io_uring` server is now the only server. The `iggy-server` binary, the
`server` package, the configs tree and the systemd integration are the former
server-ng, and all transports are kept: TCP with TLS, QUIC, WebSocket and HTTP.
The legacy server, its compat lane and the classic TCP, QUIC and WebSocket
framing were deleted (**BREAKING**).
+- **[Legacy message wire format and v2 bridge APIs
removed](https://github.com/apache/iggy/pull/3904)** from the SDK. The new
message wire format is encoded on the client side (**BREAKING**).
+- **[Namespace removed from client
headers](https://github.com/apache/iggy/pull/3836)**, a smaller header on every
request
+- **[Server-only types extracted into
`server_common`](https://github.com/apache/iggy/pull/3298)**, **[shard
allocator extracted into
`cpu_allocation`](https://github.com/apache/iggy/pull/3578)**, **[compio
executor and io_uring diagnostics extracted into a shared
crate](https://github.com/apache/iggy/pull/3331)**, the server's **[module
graph turned into an enforced DAG](https://github.com/apache/iggy/pull/4023)**,
and the dispatch spine **[split into plane-named leaves](https://github. [...]
+
+### Topic options
+
+- **[Extensible key-value options for topics, streams and
users](https://github.com/apache/iggy/pull/3880)** (**BREAKING**): retention,
durability and segment layout are per-topic decisions made at creation.
`segment_size`, `messages_required_to_save`,
`size_of_messages_required_to_save` and the new `preallocate_segments` moved
from server-wide `[system.*]` keys to the topic. Unknown keys are rejected at
the edge, never silently ignored, and `iggy options topic` (or `GET
/options/topic`) [...]
+- **[Durability policies](https://github.com/apache/iggy/pull/4092)** as
described above (**BREAKING**: `enforce_fsync` is rejected, `[system]` is
flattened into the root, and `IGGY_SYSTEM_*` variables lose the `SYSTEM_`
segment)
+
+### Storage and recovery
+
+- **[Log and index persisted concurrently, recovery
hardened](https://github.com/apache/iggy/pull/3970)**: an index is derived from
the log and is never evidence about the log. Recovery drops a divergent index
whole and rebuilds it from a byte-0 walk, checksumming every batch it accepts.
A local persist failure now stops the whole server instead of silently killing
one shard.
+- **[Torn segment tails truncated instead of
resurrected](https://github.com/apache/iggy/pull/3946)**, **[WAL suffix
truncated above the state-transfer
floor](https://github.com/apache/iggy/pull/3918)**, **[recovery slot collisions
rejected](https://github.com/apache/iggy/pull/4009)**
+- **[Offsets reserved before they are
confirmed](https://github.com/apache/iggy/pull/3975)**, **[polls completed on
the owning shard](https://github.com/apache/iggy/pull/4119)**, **[producer
batches preserved across
replication](https://github.com/apache/iggy/pull/3859)**
+- **[Data persisted on VSR backups, with a data integrity
test](https://github.com/apache/iggy/pull/3512)**
+- **[Deleted partitions and topics rolled out of parent
stats](https://github.com/apache/iggy/pull/4046)**, **[namespace caps enforced
at admission](https://github.com/apache/iggy/pull/3907)**
+- **[Expired personal access token
cleaner](https://github.com/apache/iggy/pull/3448)**
+
+### HTTP, security and operations
+
+- **[Cluster-correct HTTP listener with per-operation
RBAC](https://github.com/apache/iggy/pull/3622)**, **[HTTPS on the REST
listener](https://github.com/apache/iggy/pull/3672)**, **[CORS
honored](https://github.com/apache/iggy/pull/3646)**, **[Web UI served by the
server](https://github.com/apache/iggy/pull/3676)**
+- **[PAT limits enforced and Prometheus metrics
exposed](https://github.com/apache/iggy/pull/3773)**
+- **[Wildcard bind without an advertised address
rejected](https://github.com/apache/iggy/pull/3923)** (**BREAKING**): a
`0.0.0.0` bind says where a node listens, not where a client can reach it.
Deployments that bind a wildcard must set `node.advertised_address`. The Helm
chart derives it from the Service DNS name.
+- **[Trusted-issuer A2A JWT verification with SDK token
refresh](https://github.com/apache/iggy/pull/3626)**, **[message encryption in
the replicated server](https://github.com/apache/iggy/pull/3644)**
+- **[`read_servers` permission required for the system
snapshot](https://github.com/apache/iggy/pull/3579)**, **[`get_user` self-read
restored without `read_users`](https://github.com/apache/iggy/pull/3897)**
+- **[systemd watchdog integration](https://github.com/apache/iggy/pull/3233)**
for the server and the MCP server
+- **[Listener, auth, shutdown and disk-poll paths
hardened](https://github.com/apache/iggy/pull/3774)**, **[configuration surface
hardened](https://github.com/apache/iggy/pull/3756)**, and the **[server config
cleaned up after a full field audit](https://github.com/apache/iggy/pull/3895)**
+- **[Sibling `IGGY` environment variables
allowed](https://github.com/apache/iggy/pull/4149)**, **[deterministic
environment variable name
suggestions](https://github.com/apache/iggy/pull/3142)**
+- **[io_uring panic from an unsupported opcode
diagnosed](https://github.com/apache/iggy/pull/3589)**, **[shard startup kept
off blocking
fallbacks](https://github.com/apache/iggy/commit/8c4986ac545c1b75afd40b11c425d35b7fbbe058)**
+- **[Partitions distributed evenly in cooperative
rebalancing](https://github.com/apache/iggy/pull/3374)**
+- **[Non-numeric filenames in consumer offset directories skipped instead of
panicking](https://github.com/apache/iggy/pull/3135)**
+- **[Iggy banner on server startup](https://github.com/apache/iggy/pull/4085)**
+- Rust toolchain moved to
**[1.98](https://github.com/apache/iggy/pull/3962)**, with an **[MSRV of 1.95
declared and enforced in CI](https://github.com/apache/iggy/pull/3558)**
+
+### Breaking changes
+
+This is a major upgrade. Read this list before moving an existing deployment:
+
+- **Data directory**: the on-disk format changed, and there is no migration of
0.8.x data directories. Until 1.0.0, an upgrade means starting from a fresh
data directory.
+- **[Wire protocol](https://github.com/apache/iggy/pull/3856)**: VSR framing
is the only binary wire protocol. 0.8.x clients cannot talk to a 0.9.0 server
and vice versa. Upgrade the server and the SDKs together.
+- **[Legacy message wire format and v2 bridge APIs
removed](https://github.com/apache/iggy/pull/3904)** from the Rust SDK
+- **[Server configuration](https://github.com/apache/iggy/pull/3856)**: the
`[cluster]` and sharding sections were reworked. Review your `config.toml`.
+- **[Topic options](https://github.com/apache/iggy/pull/3880)**:
`segment_size`, `messages_required_to_save`,
`size_of_messages_required_to_save` and related keys are per-topic. The server
refuses to boot on the old `[system.*]` keys.
+- **[Durability](https://github.com/apache/iggy/pull/4092)**: `enforce_fsync`
and `consumer_offset_enforce_fsync` are rejected. `[system]` is flattened into
the root of the config and `IGGY_SYSTEM_*` variables drop `SYSTEM_`. The HTTP
`Iggy-Durability` header reports `replicated` or `persisted`.
+- **[Cluster metadata requires
authentication](https://github.com/apache/iggy/pull/3872)**: `PING` is the only
pre-auth command
+- **[Wildcard bind requires an advertised
address](https://github.com/apache/iggy/pull/3923)**, and `cluster.nodes[*].ip`
must be a literal IP
+- **[Connector SDK `ConnectivityConfig` replaced by
`RetryPolicy`](https://github.com/apache/iggy/pull/4104)** (Rust API only,
built plugins are unaffected)
+
+The release is available as a [signed source archive](/downloads/) from the
Apache Software Foundation. Docker images are available on **[Docker
Hub](https://hub.docker.com/u/apache?page=1&search=iggy)**: `apache/iggy:0.9.0`
for the server and `apache/iggy-connect:0.5.0` for the connectors runtime.
+
+---
+
+## SDKs
+
+Every SDK migrated to the VSR wire protocol and runs its test suite against
the replicated server only. All of them expose the new durability options and a
raw command API for custom commands.
+
+### Rust
+
+- **Rust client bumped to 0.11.0**
+- **[VSR header framing](https://github.com/apache/iggy/pull/3254)**, the
client side of the new protocol
+- **[Fail over to a surviving node when the current one
dies](https://github.com/apache/iggy/pull/3944)**, **[rejoin consumer groups
after membership loss](https://github.com/apache/iggy/pull/3703)**, **[late
offset stores no longer hit a left
group](https://github.com/apache/iggy/pull/3668)**
+- **[Raw requests for custom
commands](https://github.com/apache/iggy/pull/3373)** and **[unknown command
codes forwarded instead of
rejected](https://github.com/apache/iggy/pull/3766)**, so the protocol can be
extended without forking the client
+- **[Consumer group polling and commits corrected in
`IggyConsumer`](https://github.com/apache/iggy/pull/4034)**, **[consumer
progress and error handling
preserved](https://github.com/apache/iggy/pull/4151)**, **[stream terminates
after consumer shutdown](https://github.com/apache/iggy/pull/3964)**
+- **[`max_buffer_size` respected when merging
batches](https://github.com/apache/iggy/pull/3933)**, **[error callback awaits
shard flush on producer shutdown](https://github.com/apache/iggy/pull/3953)**
+- **[`NonZeroIggyDuration` for retries and
heartbeats](https://github.com/apache/iggy/pull/3891)**
+- Documentation for
**[`IggyClient`](https://github.com/apache/iggy/pull/3809)**,
**[`IggyProducer`](https://github.com/apache/iggy/pull/3989)** and
**[`IggyConsumer`](https://github.com/apache/iggy/pull/3913)**
+
+### PHP (new)
+
+A brand new SDK. **[The PHP SDK](https://github.com/apache/iggy/pull/3235)**
is a native PHP extension built in Rust with `ext-php-rs`. It wraps the Rust
SDK and exposes a synchronous `Iggy\Client` covering streams, topics, messages
and consumer groups. It **[maps Iggy errors to typed
exceptions](https://github.com/apache/iggy/pull/3382)**, ships a **[consumer
message iterator](https://github.com/apache/iggy/pull/3463)** and
**[examples](https://github.com/apache/iggy/pull/3368)**, and r [...]
+
+### Python
+
+Python received more pull requests than any other SDK in this release and
closed most of its API gap with the Rust client:
+
+- **[High-level producer API](https://github.com/apache/iggy/pull/4156)** and
**[message partitioning strategies](https://github.com/apache/iggy/pull/3927)**
+- **[TCP](https://github.com/apache/iggy/pull/3776)**,
**[QUIC](https://github.com/apache/iggy/pull/3991)**,
**[HTTP](https://github.com/apache/iggy/pull/3992)** and
**[WebSocket](https://github.com/apache/iggy/pull/4000)** transport
configuration
+- Stream and topic management: **[stream listing, update, delete,
purge](https://github.com/apache/iggy/pull/3701)**, **[topic listing, update,
delete, purge](https://github.com/apache/iggy/pull/3572)**, **[remaining Topic
fields and partitions](https://github.com/apache/iggy/pull/3623)**,
**[partition management](https://github.com/apache/iggy/pull/4017)**
+- Consumer groups: **[create and
get](https://github.com/apache/iggy/pull/3581)**, **[delete, join and
leave](https://github.com/apache/iggy/pull/3607)**, **[`poll_messages` takes a
consumer](https://github.com/apache/iggy/pull/3877)**
+- Users: **[user management](https://github.com/apache/iggy/pull/3695)**,
**[permissions and remaining auth
methods](https://github.com/apache/iggy/pull/3727)**, **[`update_user`
options](https://github.com/apache/iggy/pull/4173)**
+- **[User headers and origin
timestamp](https://github.com/apache/iggy/pull/3613)**,
**[`get_stats`](https://github.com/apache/iggy/pull/4018)**
+- **[ruff configured, modern coding
standards](https://github.com/apache/iggy/pull/3318)**, **[pyrefly type
checking](https://github.com/apache/iggy/pull/3323)**, **[Windows wheels
restored](https://github.com/apache/iggy/pull/4049)**
+
+### Java
+
+- **[TCP client migrated to the VSR wire
protocol](https://github.com/apache/iggy/pull/3841)**
+- **[Cluster metadata and leader
redirection](https://github.com/apache/iggy/pull/3745)**
+- **[TCP clients run one I/O thread or share a
group](https://github.com/apache/iggy/pull/4096)**
+- **[Wire strings sized by UTF-8 byte
length](https://github.com/apache/iggy/pull/4068)**
+- Java benchmark: **[CLI and resource
provisioner](https://github.com/apache/iggy/pull/3159)**, **[actor and data
batch generator](https://github.com/apache/iggy/pull/3218)**, **[reporting
suite](https://github.com/apache/iggy/pull/3420)**
+- **[Pinot connector E2E coverage](https://github.com/apache/iggy/pull/3922)**
+
+### C\#
+
+- **[Migrated to the VSR wire
protocol](https://github.com/apache/iggy/pull/3800)**, with the **[TCP
connection cleaned up afterwards](https://github.com/apache/iggy/pull/3858)**
+- **[Heartbeat mechanism](https://github.com/apache/iggy/pull/3894)**
+- **[Rented message polling](https://github.com/apache/iggy/pull/3250)** and
**[rented payloads](https://github.com/apache/iggy/pull/3471)** for fewer
allocations
+- **[Encryption moved to the raw
client](https://github.com/apache/iggy/pull/3639)**, **[missing metrics in
`StatsResponse`](https://github.com/apache/iggy/pull/3221)**, **[integer
properties made unsigned](https://github.com/apache/iggy/pull/4027)**
+- **[UTF-8 byte count for string
validation](https://github.com/apache/iggy/pull/4072)**, **[OS default socket
buffer size](https://github.com/apache/iggy/pull/4013)**
+
+### Go
+
+- **[Migrated to the VSR wire
protocol](https://github.com/apache/iggy/pull/3834)**
+- **[`context.Context` on client
methods](https://github.com/apache/iggy/pull/2964)**
+- **[Transport state separated from session
state](https://github.com/apache/iggy/pull/3652)**, **[structured
logger](https://github.com/apache/iggy/pull/3379)** aligned with the Rust SDK
+- **[Request-path wire payload
pooled](https://github.com/apache/iggy/pull/3442)**, **[delete
segments](https://github.com/apache/iggy/pull/3191)**
+- Fixed **[bounds checking for payloads over 64
KB](https://github.com/apache/iggy/pull/3165)**, **[S2 decode returning an
error instead of panicking](https://github.com/apache/iggy/pull/3166)**,
**[teardown of only the failed
connection](https://github.com/apache/iggy/pull/3986)**, **[`MaxPayloadSize`
raised to 64 MB](https://github.com/apache/iggy/pull/4080)**
+
+### Node.js (TypeScript)
+
+- **[VSR framing](https://github.com/apache/iggy/pull/3763)** and **[classic
framing dropped](https://github.com/apache/iggy/pull/3843)**
+- **[Connection string syntax](https://github.com/apache/iggy/pull/3917)**
+- **[TLS with VSR](https://github.com/apache/iggy/pull/3813)**, **[scoped
permissions wire codec](https://github.com/apache/iggy/pull/3642)**, **[tokens
no longer dropped in list responses](https://github.com/apache/iggy/pull/4171)**
+- **[npm prereleases published under a channel tag, not
`latest`](https://github.com/apache/iggy/pull/3370)**
+- Node.js client bumped to 0.10.0
+
+### C++
+
+The low-level bindings from 0.8.0 grew into a client:
+
+- **[Messaging FFI functions](https://github.com/apache/iggy/pull/3046)**,
**[system functions](https://github.com/apache/iggy/pull/3102)**, **[consumer
groups and topics](https://github.com/apache/iggy/pull/3506)**,
**[offsets](https://github.com/apache/iggy/pull/3559)**, **[client lifecycle
and message headers](https://github.com/apache/iggy/pull/3610)**
+- **[User management and an initial high-level
client](https://github.com/apache/iggy/pull/3733)**, extended with **[stream,
topic and partition functions](https://github.com/apache/iggy/pull/4022)**
+- **[BDD tests for basic
messaging](https://github.com/apache/iggy/pull/3554)**, **[Bazel upgraded to
9.1.1](https://github.com/apache/iggy/pull/3511)**
+
+### CLI
+
+- **CLI bumped to 0.14.0**, **[with VSR
support](https://github.com/apache/iggy/pull/3679)**
+- **[`context show` and `session status`
commands](https://github.com/apache/iggy/pull/3096)**
+- **[Pure-Rust zbus keyring instead of
libdbus](https://github.com/apache/iggy/pull/3326)**, one system dependency
fewer
+- **[Bounded ping retries](https://github.com/apache/iggy/pull/3903)**, **[CLI
input validated](https://github.com/apache/iggy/pull/4150)**
+
+---
+
+## Connectors
+
+The connectors runtime, now 0.5.0, gained eight new sinks, a new source, a new
payload format, and a long list of reliability fixes.
+
+### New connectors
+
+- **[S3 sink](https://github.com/apache/iggy/pull/3103)**
+- **[ClickHouse sink](https://github.com/apache/iggy/pull/2886)**
+- **[Delta Lake sink](https://github.com/apache/iggy/pull/2889)**
+- **[Apache Doris sink](https://github.com/apache/iggy/pull/3215)**, with
**[in-request retries](https://github.com/apache/iggy/pull/3574)** and an
**[opt-in CSV output format](https://github.com/apache/iggy/pull/3575)**
+- **[Meilisearch sink](https://github.com/apache/iggy/pull/3497)**
+- **[SurrealDB sink](https://github.com/apache/iggy/pull/3453)**
+- **[Redshift sink](https://github.com/apache/iggy/pull/3654)**
+- **[RabbitMQ sink](https://github.com/apache/iggy/pull/3973)**
+- **[HTTP source webhook gateway](https://github.com/apache/iggy/pull/3798)**:
Iggy can now receive webhooks directly. One listener serves many providers,
each routed to its own topic, with bearer or HMAC authentication, secret URL
endpoints, and a token-guarded admin API to register, re-key and revoke
endpoints at runtime. Delivery is best-effort, and the connector's README says
so up front.
+- **[InfluxDB v2 and v3
connectors](https://github.com/apache/iggy/pull/3140)** rebuilt as separate
source and sink crates
+
+### Runtime and SDK
+
+- **[Avro payload support](https://github.com/apache/iggy/pull/3141)** with
separate encoder, decoder and transform crates
+- **[`unwrap_envelope` transform and envelope
detection](https://github.com/apache/iggy/pull/3197)**, fixing source-to-sink
format mismatches
+- **[Source batch
acknowledgments](https://github.com/apache/iggy/pull/3855)**: a source's
checkpoint is saved only after Iggy accepted the batch. **[Source checkpoints
are deferred and sink failures
surfaced](https://github.com/apache/iggy/pull/4153)** instead of swallowed, and
the **[PostgreSQL source defers progress until
ack](https://github.com/apache/iggy/pull/3957)**.
+- **[Source state stored on an HTTP state
server](https://github.com/apache/iggy/pull/3940)** as an alternative to a
local file
+- **[Agent docs, per-batch observability and atomic
state](https://github.com/apache/iggy/pull/3321)**
+- **[Shared `retry_async` helper](https://github.com/apache/iggy/pull/4104)**,
replacing seven hand-rolled retry loops that all waited twice the configured
delay before the first retry (**BREAKING** for the Rust connector SDK:
`ConnectivityConfig` became `RetryPolicy`)
+- **[Per-connector init failures
isolated](https://github.com/apache/iggy/pull/3244)**, **[duplicate
`iggy_sink_open` and `iggy_source_open`
rejected](https://github.com/apache/iggy/pull/3179)**, **[connector key
validated before it becomes a
path](https://github.com/apache/iggy/pull/4083)**, **[warning when the runtime
API is exposed without a key](https://github.com/apache/iggy/pull/3804)**
+- **[Sink writes and startup readiness
validated](https://github.com/apache/iggy/pull/4154)**, **[failures reported
and format conversion corrected](https://github.com/apache/iggy/pull/4152)**
+- **[Distinct error variants instead of an overloaded
`InvalidRecord`](https://github.com/apache/iggy/pull/3194)**, **[FlatBuffer
conversions no longer fail
silently](https://github.com/apache/iggy/pull/3431)**, **[protobuf field
lengths validated consistently](https://github.com/apache/iggy/pull/4069)**
+- Fixed **[PostgreSQL CDC producing no
messages](https://github.com/apache/iggy/pull/3640)**, **[Iceberg sink
partitions on iceberg 0.10](https://github.com/apache/iggy/pull/3969)**,
**[InfluxDB v3 cursor rollback](https://github.com/apache/iggy/pull/3434)**,
**[doubled `_total` on counter
metrics](https://github.com/apache/iggy/pull/3921)**
+- **[Flink sink uses the TCP
client](https://github.com/apache/iggy/pull/3657)**
+- **[CPU and cgroup limits respected in connector
stats](https://github.com/apache/iggy/pull/3647)**
+
+Learn more in the [connectors documentation](/docs/connectors/introduction).
+
+---
+
+## Kafka gateway
+
+One of the questions we hear most often is some form of "can I point my Kafka
clients at Iggy?". Our answer has two parts. Iggy is not built on Kafka and
will not reimplement the Kafka protocol inside the server: the Iggy wire
protocol, storage engine and replication are their own design, and that is
where the performance comes from. Interoperability instead lives in a
**separate gateway process** that speaks the Kafka wire protocol on one side
and Iggy's protocol on the other, so existi [...]
+
+0.9.0 ships the first layer of that gateway. **[The Kafka wire protocol
gateway](https://github.com/apache/iggy/pull/3519)** is a new `gateways/kafka`
workspace crate: a TCP listener on the Kafka port that decodes requests,
enforces a version firewall, and answers `ApiVersions`, `Metadata`, `Produce`,
`Fetch`, `ListOffsets` and `CreateTopics`, backed by 184 regression tests over
golden wire fixtures. The community-driven rollout plan is public in the
**[Kafka to Iggy bridge discussion](h [...]
+
+To be clear about the current state: **in 0.9.0 this is the foundation layer,
not a working bridge yet.** No API persists or reads Iggy data. `Produce`,
`Fetch` and `ListOffsets` return a retriable error so Kafka clients keep their
data and retry, and `CreateTopics` does not create topics. The work has not
stopped at the tag, though. The **[bridge
core](https://github.com/apache/iggy/pull/4043)**, an Iggy SDK client with
stream and topic mapping, provisioning, high watermarks and an Iggy [...]
+
+---
+
+## Benchmark Dashboard
+
+- **[Redesigned dashboard with a landing page and compare
mode](https://github.com/apache/iggy/pull/3145)**: the [benchmarking
platform](https://benchmarks.iggy.apache.org) now opens on a showcase pick and
compares any two runs side by side
+- **[Unified benchmarks list and best-pick
selection](https://github.com/apache/iggy/pull/3146)**, **[mobile layout and
compare URLs](https://github.com/apache/iggy/pull/3148)** fixed
+- **[Cluster topology recorded in reports and surfaced in the
UI](https://github.com/apache/iggy/pull/3737)**, so a result says whether it
came from one node or three
+- `iggy-bench` **[exposes both durability
policies](https://github.com/apache/iggy/pull/4092)** as `--durability` and
`--consumer-offset-durability` flags
+- Bench dashboard bumped to 0.8.0
+
+---
+
+## Web UI and MCP
+
+- Web UI bumped to **0.4.0** and **[served directly by the
server](https://github.com/apache/iggy/pull/3676)**
+- **[Explicit CSS import for the latest Vite
build](https://github.com/apache/iggy/pull/3502)**, frontend dependencies
updated
+- MCP server bumped to **0.5.0**, **[runs against the replicated
server](https://github.com/apache/iggy/pull/3617)** and supports the **[systemd
watchdog](https://github.com/apache/iggy/pull/3233)**
+
+---
+
+## Helm Charts
+
+- **[Cluster deployment with one release per
node](https://github.com/apache/iggy/pull/4035)**: `server.cluster` mirrors the
server's `[[cluster.nodes]]` roster one for one and renders into
`IGGY_CLUSTER_*` variables plus `--replica-id`. Each node is its own release
overriding only `selfReplicaId`, with a three-node example to start from.
Roster mistakes fail at render time. `server.replicaCount > 1` and autoscaling
are now refused, because three independent servers behind one Service sh [...]
+- Fixed QUIC published as TCP, a missing WebSocket port, Kubernetes service
links leaking `IGGY_*` variables into the server config, and the default image
moved off 0.7.0
+- **[Gateway API and Envoy Gateway replace
ingress-nginx](https://github.com/apache/iggy/pull/3386)** in the smoke cluster
+
+---
+
+## CI/CD & Infrastructure
+
+- **[Incubating references removed after TLP
graduation](https://github.com/apache/iggy/pull/3928)**
+- **[SECURITY.md with the ASF reporting
process](https://github.com/apache/iggy/pull/3787)**, **[package tokens
replaced with OIDC](https://github.com/apache/iggy/pull/3892)**, **[read-only
permissions declared on reusable
workflows](https://github.com/apache/iggy/pull/3243)**
+- **[Miri undefined behavior
detector](https://github.com/apache/iggy/pull/3284)** for `binary_protocol` and
`consensus`, **[build fails on rustdoc and rustc
warnings](https://github.com/apache/iggy/pull/3925)**
+- **[Integration tests run when a binary they launch
changes](https://github.com/apache/iggy/pull/4131)**, **[a plugin's integration
suite runs when it changes](https://github.com/apache/iggy/pull/4077)**,
**[Docker `:edge` refresh gated by the cargo-rail
DAG](https://github.com/apache/iggy/pull/3369)**
+- **[HawkEye replaces addlicense](https://github.com/apache/iggy/pull/3325)**
for license header checks, later **[migrated to
v7](https://github.com/apache/iggy/pull/3952)**
+- PR triage automation: **[review state labels via slash
commands](https://github.com/apache/iggy/pull/3231)**, **[`/pin` and
`/unpin`](https://github.com/apache/iggy/pull/4060)**, **[welcome
comment](https://github.com/apache/iggy/pull/3261)**, **[auto-assign
volunteering issue authors](https://github.com/apache/iggy/pull/3400)**,
**[fork-PR commands via `workflow_run`
handoff](https://github.com/apache/iggy/pull/3268)**, and a stale bot that
**[leaves PRs waiting on review alone](https [...]
+- **[AI assistance expectations added to
CONTRIBUTING](https://github.com/apache/iggy/pull/4081)** and a **[repo-wide
team-review agent skill](https://github.com/apache/iggy/pull/4004)**
+- **[Devcontainer for reproducible
development](https://github.com/apache/iggy/pull/3656)**
+- Most triage and status jobs moved to **[ARM
runners](https://github.com/apache/iggy/pull/3270)**, runner disk cleanup
**[shared across PHP, Rust, BDD and
coverage](https://github.com/apache/iggy/pull/3330)**, **[apt-get bounded so a
dead mirror cannot wedge a job](https://github.com/apache/iggy/pull/3924)**
+- BDD runners **[fail on undefined
steps](https://github.com/apache/iggy/pull/3680)**, **[stream CRUD
coverage](https://github.com/apache/iggy/pull/3875)** added, **[integration
suites duplicating existing coverage
removed](https://github.com/apache/iggy/pull/3823)**
+- **[Source release tarball includes
gateways](https://github.com/apache/iggy/pull/4126)**, **[DEPENDENCIES.md
dropped per ASF policy](https://github.com/apache/iggy/pull/3222)**
+
+---
+
+## What's Next
+
+Clustering is in. The next releases are about making it faster and complete:
+
+- **Performance**: the effort behind the benchmark tables above continues.
Deferred polling so consumers stop paying for empty round trips, a task per
read and write for more I/O concurrency, more replica links per node pair,
explicit group commit, Direct I/O instead of `fsync`, and more copy-free paths
of the kind 0.9.0 started. Further out, multi-leader clusters with a leader per
partition spread across all nodes. A full 0.8.0 vs 0.9.0 vs next comparison is
planned for the benchmarking [...]
+- **Kafka gateway**: the bridge from the protocol listener into Iggy streams,
so Kafka producers and consumers can talk to Iggy through the gateway. Consumer
groups follow, as laid out in the [rollout
discussion](https://github.com/apache/iggy/discussions/3253).
+- **SDK parity**: leader redirection coverage for Python, Node.js and C++, and
the PHP SDK on its way to a published package
+- **Connectors**: more sources, bounded backpressure end to end, and runtime
hardening
+- **Agentic AI**: building on the A2A support and the MCP server
+
+---
+
+Thanks to our amazing community and contributors for making Apache Iggy better
with every release. Sixty people contributed to 0.9.0, and 34 of them landed
their first Iggy pull request in this cycle. Welcome aboard.
+
+**Join us on [Discord](https://discord.gg/apache-iggy) and help shape the
future of the project!**
diff --git a/scripts/diagram-text.mjs b/scripts/diagram-text.mjs
index 1c45bb8e6..e0069ea64 100644
--- a/scripts/diagram-text.mjs
+++ b/scripts/diagram-text.mjs
@@ -30,6 +30,9 @@
const SITE = "https://iggy.apache.org";
export const DIAGRAM_TEXT = {
+ Bench090DurabilityChart: `**Chart: Replicated vs. Persisted.** Side-by-side
panels compare the 0.9.0 release and the next-version development snapshot.
Each panel shows Replicated and Persisted producer latency on the same linear
scale starting at zero. Controls select single node or 3-node cluster and p50,
p99 or p999. All four measurements use 20 pinned producers with an aggregate
rate limit of 800 MB/s. Replicated waits for quorum commit and local
application. Persisted also require [...]
+ Bench090LatencyChart: `**Chart: latency, 0.9.0 versus a development
snapshot, per iggy-bench workload.** Three headline tiles (3-node cluster,
persisted, 20 producers at p999 and p99, and single node, 20 producers at
p999), then an interactive chart limited to workloads with lower p50, p99 and
p999 latency, with two dots per row on a log scale, one for the 0.9.0 release
and one for the development branch that follows it. It switches between single
node and 3-node cluster, between p50, [...]
+
AppendOnlyLogViz: `**Diagram: Append-only log.** Messages are appended in
order, each with the next offset (0, 1, 2 and so on). New messages are always
written at the end. A consumer tracks its own position in the log with an
offset, independently of other consumers.`,
BenchmarkChart: `**Diagram: Latency improvements, Tokio vs
thread-per-core.** Selected historical results comparing v0.5.0 (Tokio) with
v0.7.0 (thread-per-core) at about 1,000 MB/s per node. Lower latency is better.
diff --git a/scripts/generate-docs-markdown.mjs
b/scripts/generate-docs-markdown.mjs
index e7d0d0427..dbd836188 100644
--- a/scripts/generate-docs-markdown.mjs
+++ b/scripts/generate-docs-markdown.mjs
@@ -86,7 +86,7 @@ function transformBody(source, body) {
return line;
}
if (inFence) return line;
- const component = /^\s*<([A-Z][A-Za-z]*)\s*\/>\s*$/.exec(line);
+ const component = /^\s*<([A-Z][A-Za-z0-9]*)\s*\/>\s*$/.exec(line);
if (component) {
const text = DIAGRAM_TEXT[component[1]];
if (!text) {
diff --git a/src/app/global.css b/src/app/global.css
index 76a9c6db9..e0d961348 100644
--- a/src/app/global.css
+++ b/src/app/global.css
@@ -129,6 +129,7 @@ figure.shiki {
border-radius: 0.375rem;
padding: 0.15em 0.4em;
font-size: 0.875em;
+ overflow-wrap: anywhere;
}
.fd-prose a {
diff --git a/src/components/bench-0-9-0.tsx b/src/components/bench-0-9-0.tsx
new file mode 100644
index 000000000..344bc94d3
--- /dev/null
+++ b/src/components/bench-0-9-0.tsx
@@ -0,0 +1,461 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+"use client";
+
+import { useId, useState } from "react";
+
+/**
+ * Latency comparison for the 0.9.0 release post: the 0.9.0 release against
+ * the development branch that follows it, per iggy-bench workload. Three
+ * headline tiles on top, then selected improving workloads with two dots on a
log
+ * axis joined by a line. The tables in the post carry the same numbers, so
+ * the chart never gates a value.
+ */
+
+type Pair = [before: number, after: number];
+
+type Row = {
+ workload: string;
+ mbps: Pair;
+ p50: Pair;
+ p99: Pair;
+ p999: Pair;
+};
+
+type Percentile = "p50" | "p99" | "p999";
+type SetupKey = "single" | "cluster";
+type Order = "table" | "change";
+
+type Setup = {
+ key: SetupKey;
+ label: string;
+ rows: Row[];
+};
+
+const SERIES = {
+ before: { label: "0.9.0", color: "#8b93a1" },
+ after: { label: "Next version", color: "#fb4800" },
+} as const;
+
+const PERCENTILES: { key: Percentile; label: string }[] = [
+ { key: "p50", label: "p50" },
+ { key: "p99", label: "p99" },
+ { key: "p999", label: "p999" },
+];
+
+const ORDERS: { key: Order; label: string }[] = [
+ { key: "table", label: "Table order" },
+ { key: "change", label: "Biggest change" },
+];
+
+const SETUPS: Setup[] = [
+ {
+ key: "single",
+ label: "Single node",
+ rows: [
+ { workload: "pinned producer, 20 actors, 800 MB/s", mbps: [800.1,
800.1], p50: [0.65, 0.588], p99: [1.313, 1.145], p999: [11.087, 2.956] },
+ { workload: "pinned producer, 20 actors, persisted, 800 MB/s", mbps:
[800.0, 800.1], p50: [1.232, 1.098], p99: [1.601, 1.424], p999: [2.249, 1.967]
},
+ { workload: "pinned consumer, 20 actors, cold, 800 MB/s", mbps: [800.1,
800.1], p50: [0.498, 0.447], p99: [4.698, 3.313], p999: [5.156, 3.777] },
+ { workload: "pinned consumer, 20 actors, warm, 800 MB/s", mbps: [800.1,
800.1], p50: [0.521, 0.544], p99: [1.022, 0.923], p999: [1.573, 3.92] },
+ { workload: "pinned producer, 1 actor, 500 MB/s", mbps: [500, 500], p50:
[0.347, 0.352], p99: [0.679, 0.683], p999: [1.356, 1.046] },
+ { workload: "pinned consumer, 1 actor, warm, 500 MB/s", mbps: [500,
500], p50: [0.272, 0.296], p99: [0.468, 0.478], p999: [0.491, 0.502] },
+ { workload: "pinned producer, 1 actor, persisted, 500 MB/s", mbps:
[372.1, 335.8], p50: [0.665, 0.733], p99: [0.825, 0.901], p999: [0.995, 1.151]
},
+ ],
+ },
+ {
+ key: "cluster",
+ label: "3-node cluster",
+ rows: [
+ { workload: "pinned producer, 20 actors, persisted, 800 MB/s", mbps:
[800.0, 800.0], p50: [2.905, 2.243], p99: [7.073, 2.938], p999: [12.403, 3.889]
},
+ { workload: "pinned producer, 1 actor, persisted, 500 MB/s", mbps:
[234.4, 233.6], p50: [1.047, 1.062], p99: [1.2, 1.223], p999: [10.857, 1.759] },
+ { workload: "pinned consumer, 20 actors, cold, 800 MB/s", mbps: [800.1,
800.0], p50: [0.477, 0.436], p99: [3.831, 2.346], p999: [4.241, 2.675] },
+ { workload: "pinned consumer, 20 actors, warm, 800 MB/s", mbps: [800.1,
797.9], p50: [0.484, 0.451], p99: [0.933, 0.812], p999: [1.233, 1.216] },
+ { workload: "pinned producer, 20 actors, 800 MB/s", mbps: [800.0,
800.0], p50: [1.782, 1.679], p99: [3.186, 3.25], p999: [3.683, 3.66] },
+ { workload: "pinned producer, 1 actor, 500 MB/s", mbps: [337.2, 325.4],
p50: [0.716, 0.735], p99: [0.973, 1.016], p999: [1.057, 1.13] },
+ { workload: "pinned consumer, 1 actor, warm, 500 MB/s", mbps: [500.0,
500.3], p50: [0.297, 0.326], p99: [0.493, 0.536], p999: [0.533, 0.574] },
+ ],
+ },
+];
+
+/** The three results the post leads with. Clicking a tile opens that row in
the chart. */
+const HIGHLIGHTS: { setup: SetupKey; workload: string; percentile: Percentile;
label: string }[] = [
+ { setup: "cluster", workload: "pinned producer, 20 actors, persisted, 800
MB/s", percentile: "p999", label: "3-node cluster, persisted, 20 producers,
p999" },
+ { setup: "cluster", workload: "pinned producer, 20 actors, persisted, 800
MB/s", percentile: "p99", label: "3-node cluster, persisted, 20 producers, p99"
},
+ { setup: "single", workload: "pinned producer, 20 actors, 800 MB/s",
percentile: "p999", label: "Single node, 20 producers, p999" },
+];
+
+/** Candidate tick positions for the log axis, in milliseconds. */
+const TICKS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100];
+
+function axisTicks(values: number[]): number[] {
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ let lo = 0;
+ let hi = TICKS.length - 1;
+ for (let i = 0; i < TICKS.length; i++) {
+ if (TICKS[i] <= min) lo = i;
+ }
+ for (let i = TICKS.length - 1; i >= 0; i--) {
+ if (TICKS[i] >= max) hi = i;
+ }
+ return TICKS.slice(lo, hi + 1);
+}
+
+function logPosition(value: number, lo: number, hi: number): number {
+ const a = Math.log10(lo);
+ const b = Math.log10(hi);
+ return ((Math.log10(value) - a) / (b - a)) * 100;
+}
+
+// Keep CSS coordinates stable across server and browser floating-point math.
+function positionPercent(value: number): string {
+ return `${Number(value.toFixed(3))}%`;
+}
+
+function formatMs(value: number): string {
+ return value.toLocaleString("en-US", { maximumFractionDigits: 3 });
+}
+
+function formatMbps(value: number): string {
+ return value.toLocaleString("en-US", { maximumFractionDigits: 1 });
+}
+
+function deltaPercent([before, after]: Pair): number {
+ return ((after - before) / before) * 100;
+}
+
+function formatDelta(pct: number): string {
+ const rounded = Math.round(pct);
+ if (rounded === 0) return "0%";
+ return `${rounded > 0 ? "+" : "-"}${Math.abs(rounded)}%`;
+}
+
+function deltaClass(pct: number): string {
+ const rounded = Math.round(pct);
+ if (rounded < 0) return "text-emerald-700 dark:text-emerald-400";
+ if (rounded > 0) return "text-red-700 dark:text-red-400";
+ return "text-fd-muted-foreground";
+}
+
+function findSetup(key: SetupKey): Setup {
+ return SETUPS.find((s) => s.key === key) ?? SETUPS[0];
+}
+
+function Segmented<T extends string>({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: { key: T; label: string }[];
+ onChange: (key: T) => void;
+}) {
+ return (
+ <div role="group" aria-label={label} className="inline-flex rounded-md
border border-fd-border p-0.5">
+ {options.map((option) => {
+ const active = option.key === value;
+ return (
+ <button
+ key={option.key}
+ type="button"
+ aria-pressed={active}
+ onClick={() => onChange(option.key)}
+ className={`rounded px-2.5 py-1 text-xs font-medium
transition-colors ${
+ active
+ ? "bg-fd-primary/15 text-fd-foreground"
+ : "text-fd-muted-foreground hover:text-fd-foreground"
+ }`}
+ >
+ {option.label}
+ </button>
+ );
+ })}
+ </div>
+ );
+}
+
+export function Bench090DurabilityChart() {
+ const [setupKey, setSetupKey] = useState<SetupKey>("cluster");
+ const [percentile, setPercentile] = useState<Percentile>("p50");
+ const titleId = useId();
+ const setup = findSetup(setupKey);
+ const policies = [
+ { label: "Replicated", workload: "pinned producer, 20 actors, 800 MB/s",
color: "#3987e5" },
+ { label: "Persisted", workload: "pinned producer, 20 actors, persisted,
800 MB/s", color: "#fb4800" },
+ ].map((policy) => ({
+ ...policy,
+ row: setup.rows.find((row) => row.workload === policy.workload)!,
+ }));
+ const maximum = Math.ceil(Math.max(...policies.flatMap(({ row }) =>
row[percentile])));
+
+ return (
+ <section aria-labelledby={titleId} className="not-prose my-8 rounded-xl
border border-fd-border bg-fd-card p-4 sm:p-6">
+ <h3 id={titleId} className="m-0 text-lg font-semibold
text-fd-foreground">Replicated vs. Persisted</h3>
+ <p className="m-0 mt-2 text-sm font-medium leading-relaxed
text-fd-foreground">20 pinned producers · 800 MB/s aggregate rate limit</p>
+ <p className="m-0 mt-1 text-xs leading-relaxed
text-fd-muted-foreground">Compare both durability policies within each version.
Next version is a development snapshot using its own iggy-bench client.</p>
+
+ <div className="mt-5 flex flex-wrap gap-3">
+ <Segmented label="Durability deployment" value={setupKey}
options={SETUPS.map(({ key, label }) => ({ key, label }))}
onChange={setSetupKey} />
+ <Segmented label="Durability percentile" value={percentile}
options={PERCENTILES} onChange={setPercentile} />
+ </div>
+
+ <div className="mt-5 grid gap-4 sm:grid-cols-2">
+ {([0, 1] as const).map((version) => (
+ <div key={version} className="min-w-0 rounded-lg border
border-fd-border bg-fd-muted/20 p-4">
+ <h4 className="m-0 text-sm font-semibold
text-fd-foreground">{version === 0 ? "0.9.0" : "Next version"}</h4>
+ <div className="mt-5 space-y-5">
+ {policies.map(({ label, color, row }) => (
+ <div key={label}>
+ <div className="mb-2 flex items-baseline justify-between
gap-2 text-sm">
+ <span className="font-medium
text-fd-foreground">{label}</span>
+ <span className="shrink-0 font-mono tabular-nums
text-fd-foreground">{formatMs(row[percentile][version])} ms</span>
+ </div>
+ <div aria-hidden="true" className="h-3 overflow-hidden
rounded-full bg-fd-muted">
+ <div className="h-full rounded-full
motion-safe:transition-[width] motion-safe:duration-300" style={{ width:
positionPercent(row[percentile][version] / maximum * 100), backgroundColor:
color }} />
+ </div>
+ <p className="m-0 mt-1 text-[11px] leading-relaxed
tabular-nums text-fd-muted-foreground">Achieved:
{formatMbps(row.mbps[version])} MB/s</p>
+ </div>
+ ))}
+ </div>
+ <div aria-hidden="true" className="mt-3 flex justify-between
border-t border-fd-border pt-1 text-[10px] tabular-nums
text-fd-muted-foreground"><span>0</span><span>{maximum} ms</span></div>
+ </div>
+ ))}
+ </div>
+
+ <p className="m-0 mt-4 text-xs leading-relaxed
text-fd-muted-foreground">{setup.label}, {percentile} latency. Both panels use
the same linear scale from zero. Lower is better.</p>
+ <p className="m-0 mt-2 text-xs leading-relaxed
text-fd-muted-foreground"><strong>Replicated:</strong> quorum commit and local
application. <strong>Persisted:</strong> also requires recoverable copies on
stable storage at the quorum. On a single node, the quorum is one.</p>
+ </section>
+ );
+}
+
+export function Bench090LatencyChart() {
+ const [setupKey, setSetupKey] = useState<SetupKey>("cluster");
+ const [percentile, setPercentile] = useState<Percentile>("p999");
+ const [order, setOrder] = useState<Order>("table");
+ const [hovered, setHovered] = useState<string | null>(null);
+ const [focused, setFocused] = useState<string | null>(null);
+ const [pinned, setPinned] = useState<string | null>(null);
+ const titleId = useId();
+
+ const setup = findSetup(setupKey);
+ const improvingRows = setup.rows.filter((row) =>
+ PERCENTILES.every(({ key }) => row[key][1] < row[key][0]),
+ );
+ const values = improvingRows.flatMap((row) => row[percentile]);
+ const ticks = axisTicks(values);
+ const lo = ticks[0];
+ const hi = ticks[ticks.length - 1];
+ const x = (value: number) => logPosition(value, lo, hi);
+
+ const rows =
+ order === "change"
+ ? [...improvingRows].sort((a, b) =>
Math.abs(deltaPercent(b[percentile])) - Math.abs(deltaPercent(a[percentile])))
+ : improvingRows;
+
+ const active = focused ?? hovered ?? pinned;
+
+ const openHighlight = (h: (typeof HIGHLIGHTS)[number]) => {
+ setSetupKey(h.setup);
+ setPercentile(h.percentile);
+ setPinned(h.workload);
+ };
+
+ const gridColumns = "grid-cols-[minmax(0,1fr)_3rem]
sm:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_3.25rem]";
+
+ return (
+ <div className="not-prose my-8 rounded-xl border border-fd-border
bg-fd-card p-4 sm:p-6" aria-labelledby={titleId}>
+ <h3 id={titleId} className="m-0 text-lg font-semibold
text-fd-foreground">
+ Latency improvements: 0.9.0 → next version
+ </h3>
+ <p className="m-0 mt-2 text-sm font-medium leading-relaxed
text-fd-foreground">
+ All shown workloads use an aggregate rate limit of 800 MB/s.
+ </p>
+ <p className="m-0 mt-1 text-xs leading-relaxed text-fd-muted-foreground">
+ Selected workloads with lower p50, p99 and p999 latency. Next version
is a development snapshot, using its own iggy-bench client. Milliseconds on a
log scale, lower is better. Expand “Full benchmark results” below for all
measurements, including regressions and achieved throughput.
+ </p>
+
+ {/* Headline tiles */}
+ <div className="mt-5 grid gap-3 sm:grid-cols-3">
+ {HIGHLIGHTS.map((h) => {
+ const row = findSetup(h.setup).rows.find((r) => r.workload ===
h.workload);
+ if (!row) return null;
+ const [before, after] = row[h.percentile];
+ const pct = deltaPercent(row[h.percentile]);
+ const selected = setupKey === h.setup && percentile === h.percentile
&& pinned === h.workload;
+ return (
+ <button
+ key={h.label}
+ type="button"
+ onClick={() => openHighlight(h)}
+ aria-pressed={selected}
+ className={`rounded-lg border p-3 text-left transition-colors
hover:border-fd-primary/50 focus-visible:outline-none focus-visible:ring-1
focus-visible:ring-fd-primary/60 ${
+ selected ? "border-fd-primary/50 bg-fd-primary/5" :
"border-fd-border bg-fd-muted/30"
+ }`}
+ >
+ <span className="block text-[11px] leading-snug
text-fd-muted-foreground">{h.label}</span>
+ <span className="mt-1.5 flex items-baseline gap-2">
+ <span className="text-2xl font-semibold
text-fd-foreground">{formatMs(after)}</span>
+ <span className="text-xs text-fd-muted-foreground">ms</span>
+ <span className={`ml-auto text-sm font-semibold tabular-nums
${deltaClass(pct)}`}>{formatDelta(pct)}</span>
+ </span>
+ <span className="mt-0.5 block text-[11px]
text-fd-muted-foreground">from {formatMs(before)} ms on 0.9.0</span>
+ </button>
+ );
+ })}
+ </div>
+
+ {/* Filters and legend */}
+ <div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2">
+ <Segmented
+ label="Deployment"
+ value={setupKey}
+ options={SETUPS.map((s) => ({ key: s.key, label: s.label }))}
+ onChange={(key) => {
+ setSetupKey(key);
+ setPinned(null);
+ }}
+ />
+ <Segmented label="Percentile" value={percentile} options={PERCENTILES}
onChange={setPercentile} />
+ <Segmented label="Order" value={order} options={ORDERS}
onChange={setOrder} />
+ <ul className="m-0 ml-auto flex list-none items-center gap-4 p-0
text-xs text-fd-muted-foreground">
+ {(["before", "after"] as const).map((key) => (
+ <li key={key} className="flex items-center gap-1.5">
+ <span className="inline-block h-2.5 w-2.5 rounded-full" style={{
backgroundColor: SERIES[key].color }} aria-hidden="true" />
+ {SERIES[key].label}
+ </li>
+ ))}
+ </ul>
+ </div>
+
+ {/* Axis */}
+ <div className={`mt-5 grid ${gridColumns} items-end gap-x-3 px-1`}>
+ <div className="col-span-2 mb-2 text-[11px] text-fd-muted-foreground
sm:col-span-1 sm:mb-0">
+ {setup.label}, {percentile}
+ </div>
+ <div className="relative h-4">
+ {ticks.map((tick) => (
+ <span
+ key={tick}
+ className="absolute bottom-0 -translate-x-1/2 text-[11px]
tabular-nums text-fd-muted-foreground"
+ style={{ left: positionPercent(x(tick)) }}
+ >
+ {tick}
+ </span>
+ ))}
+ </div>
+ <div className="text-right text-[11px]
text-fd-muted-foreground">change</div>
+ </div>
+
+ {/* Rows */}
+ <ul className="m-0 mt-1 list-none p-0">
+ {rows.map((row) => {
+ const [before, after] = row[percentile];
+ const pct = deltaPercent(row[percentile]);
+ const isActive = active === row.workload;
+ const workloadLabel = row.workload.replace(/, 800 MB\/s$/, "");
+ const left = Math.min(x(before), x(after));
+ const width = Math.abs(x(after) - x(before));
+
+ return (
+ <li key={row.workload} className="m-0 p-0">
+ <button
+ type="button"
+ onMouseEnter={() => setHovered(row.workload)}
+ onMouseLeave={() => setHovered(null)}
+ onFocus={() => setFocused(row.workload)}
+ onBlur={() => setFocused(null)}
+ onClick={() => setPinned(pinned === row.workload ? null :
row.workload)}
+ aria-pressed={pinned === row.workload}
+ aria-label={`${row.workload}: ${percentile}
${formatMs(before)} ms on 0.9.0, ${formatMs(after)} ms on development,
${formatDelta(pct)}. Throughput ${formatMbps(row.mbps[0])} to
${formatMbps(row.mbps[1])} MB/s`}
+ className={`relative grid ${gridColumns} w-full items-center
gap-x-3 rounded-md px-1 py-2 text-left outline-none transition-colors
hover:bg-fd-muted/40 focus-visible:bg-fd-muted/40 focus-visible:ring-1
focus-visible:ring-fd-primary/60 sm:py-1 ${
+ isActive ? "bg-fd-muted/40" : ""
+ }`}
+ >
+ <span className="col-span-2 block sm:col-span-1">
+ <span className="block text-xs leading-snug
text-fd-foreground/85">{workloadLabel}</span>
+ {row.workload.includes("1 actor") && (
+ <span className="block text-[10px] leading-snug
tabular-nums text-fd-muted-foreground">
+ {formatMbps(row.mbps[0])} to {formatMbps(row.mbps[1])}
MB/s
+ </span>
+ )}
+ </span>
+
+ <span className="relative block h-7">
+ {ticks.map((tick) => (
+ <span
+ key={tick}
+ aria-hidden="true"
+ className="absolute inset-y-0 w-px bg-fd-border"
+ style={{ left: positionPercent(x(tick)) }}
+ />
+ ))}
+ <span
+ aria-hidden="true"
+ className="absolute top-1/2 h-0.5 -translate-y-1/2
rounded-full bg-fd-muted-foreground/40 motion-safe:transition-[left,width]
motion-safe:duration-300"
+ style={{ left: positionPercent(left), width:
positionPercent(width) }}
+ />
+ <span
+ aria-hidden="true"
+ className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2
-translate-y-1/2 rounded-full ring-2 ring-fd-card motion-safe:transition-[left]
motion-safe:duration-300"
+ style={{ left: positionPercent(x(before)),
backgroundColor: SERIES.before.color }}
+ />
+ <span
+ aria-hidden="true"
+ className={`absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2
-translate-y-1/2 rounded-full ring-2 ring-fd-card
motion-safe:transition-[left,transform] motion-safe:duration-300 ${
+ isActive ? "scale-125" : ""
+ }`}
+ style={{ left: positionPercent(x(after)), backgroundColor:
SERIES.after.color }}
+ />
+ </span>
+
+ {isActive && (
+ <span
+ role="tooltip"
+ className="pointer-events-none absolute inset-x-0
bottom-full z-10 mb-2 max-w-full rounded-md border border-fd-border
bg-fd-popover px-3 py-2 text-left text-xs shadow-lg sm:left-auto sm:right-12
sm:w-max sm:max-w-[20rem]"
+ >
+ <span className="block font-medium
text-fd-foreground">{workloadLabel}</span>
+ <span className="mt-1 flex items-center gap-2
tabular-nums">
+ <span className="inline-block h-0.5 w-3 rounded-full"
style={{ backgroundColor: SERIES.before.color }} aria-hidden="true" />
+ <span className="font-semibold
text-fd-foreground">{formatMs(before)} ms</span>
+ <span className="text-fd-muted-foreground">0.9.0,
{formatMbps(row.mbps[0])} MB/s</span>
+ </span>
+ <span className="mt-0.5 flex items-center gap-2
tabular-nums">
+ <span className="inline-block h-0.5 w-3 rounded-full"
style={{ backgroundColor: SERIES.after.color }} aria-hidden="true" />
+ <span className="font-semibold
text-fd-foreground">{formatMs(after)} ms</span>
+ <span className="text-fd-muted-foreground">dev,
{formatMbps(row.mbps[1])} MB/s</span>
+ </span>
+ </span>
+ )}
+
+ <span className={`text-right text-xs font-medium tabular-nums
${deltaClass(pct)}`}>{formatDelta(pct)}</span>
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+
+ <p className="m-0 mt-4 text-[11px] leading-relaxed
text-fd-muted-foreground">
+ Change is the {percentile} latency on the development branch relative
to 0.9.0. Biggest change sorts by the largest percentage reduction. Click a row
to keep its values open. Snapshot of work in progress, not a final result.
+ </p>
+ </div>
+ );
+}
diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx
index d321daeee..f982e966f 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -36,6 +36,7 @@ import {
QuickStartSnippet,
DocsHero,
} from "@/components/architecture-diagrams";
+import { Bench090DurabilityChart, Bench090LatencyChart } from
"@/components/bench-0-9-0";
import type { MDXComponents } from "mdx/types";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
@@ -57,6 +58,8 @@ export function getMDXComponents(components?: MDXComponents):
MDXComponents {
WhyIggy,
QuickStartSnippet,
DocsHero,
+ Bench090DurabilityChart,
+ Bench090LatencyChart,
...components,
};
}