This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch iggy_0.9.0
in repository https://gitbox.apache.org/repos/asf/iggy-website.git

commit 5cdc88e721db24dbb50d2f698bd6088483072ab2
Author: spetz <[email protected]>
AuthorDate: Sat Sep 19 21:53:40 2026 +0200

    blog: add post about Iggy 0.9.0 release
---
 content/blog/release-0.9.0.mdx | 464 +++++++++++++++++++++++++++++++++++++++++
 scripts/diagram-text.mjs       |   2 +
 src/components/bench-0-9-0.tsx | 401 +++++++++++++++++++++++++++++++++++
 src/mdx-components.tsx         |   2 +
 4 files changed, 869 insertions(+)

diff --git a/content/blog/release-0.9.0.mdx b/content/blog/release-0.9.0.mdx
new file mode 100644
index 000000000..9783541c6
--- /dev/null
+++ b/content/blog/release-0.9.0.mdx
@@ -0,0 +1,464 @@
+---
+title: Apache Iggy 0.9.0 Release
+author: piotr
+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](https://github.com/apache/iggy/releases/tag/server-0.9.0)**. 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  [...]
+
+**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 reco [...]
+
+### 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`:
+
+```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 }
+```
+
+```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 the current development branch**, both measured with 
`iggy-bench` on the same host with identical workload parameters. Each cell 
reads `0.9.0 -> next`. Latencies are in milliseconds.
+
+A short legend. *Pinned producer* and *pinned consumer* are the `iggy-bench` 
kinds where every actor owns its own stream and partition. *Actors* is the 
number of concurrent producers or consumers. Rate-limited rows hold the 
aggregate throughput at 800 MB/s or 500 MB/s, so the latency columns compare 
like with like. *No rate limit* rows push as hard as the client can. 
*Persisted* rows use topics created with `durability=persisted`, so every 
acknowledgement waits for stable storage at the  [...]
+
+<Bench090LatencyChart />
+
+**Single node**
+
+| Workload | MB/s | p50 ms | p99 ms | p999 ms |
+| --- | --- | --- | --- | --- |
+| pinned producer, 20 actors, 800 MB/s | 800.1 -> 800.1 | 0.65 -> 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.92 |
+| pinned producer, 1 actor, 500 MB/s | 500 -> 500 | 0.347 -> 0.352 | 0.679 -> 
0.683 | 1.356 -> 1.046 |
+| pinned consumer, 1 actor, warm, 500 MB/s | 500 -> 500 | 0.272 -> 0.296 | 
0.468 -> 0.478 | 0.491 -> 0.502 |
+| pinned producer, 20 actors, no rate limit | 2062.1 -> 2096.5 | 1.086 -> 
0.792 | 26.597 -> 46.431 | 40.869 -> 78.082 |
+| pinned consumer, 20 actors, no rate limit | 2713.4 -> 2532.5 | 0.679 -> 
0.619 | 6.151 -> 5.869 | 6.476 -> 23.418 |
+| 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**
+
+| Workload | 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.2 -> 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.25 | 3.683 -> 3.66 |
+| pinned producer, 20 actors, no rate limit | 1134.5 -> 1132.5 | 4.366 -> 
4.357 | 5.159 -> 5.296 | 6.299 -> 5.929 |
+| pinned consumer, 20 actors, no rate limit | 2710.1 -> 2594.3 | 0.682 -> 
0.657 | 6.12 -> 5.758 | 6.445 -> 23.362 |
+| pinned producer, 1 actor, 500 MB/s | 337.2 -> 325.4 | 0.716 -> 0.735 | 0.973 
-> 1.016 | 1.057 -> 1.13 |
+| 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 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. A replicated, 
persisted write on a three-node cluster now completes in about 2.2 ms at the 
median while the c [...]
+
+Not every row moved in the right direction. The unthrottled rows show higher 
tails on the development branch, and a few single-actor rows are slightly 
slower. **These numbers are a snapshot of work in progress, not a final 
result.** The branch is being profiled and tuned as we write this, and the next 
version will carry further changes aimed at both the throughput and the tail 
columns. We're publishing the snapshot anyway, because we'd rather show where 
the work stands than wait for a pe [...]
+
+What is in the pipeline, in the order we expect it to land:
+
+- **[Deferred polling](https://github.com/apache/iggy/issues/3470)**: a poll 
carries a wait timeout, and when a partition has nothing new the server holds 
the request and completes it as soon as messages arrive or the timeout expires, 
instead of answering empty and making the consumer ask again. Fewer wasted 
round trips, less CPU churn on both sides, lower end-to-end latency.
+- **More concurrency in I/O**: one task per read and per write, so disk and 
network operations overlap instead of waiting on each other within a shard. The 
first piece is already in review: **[read-ahead on replica 
sockets](https://github.com/apache/iggy/pull/4224)**, so one socket read serves 
a whole burst of replication frames instead of two reads per frame.
+- **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)
+
+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, 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 
platform.
+- **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..79858f3b4 100644
--- a/scripts/diagram-text.mjs
+++ b/scripts/diagram-text.mjs
@@ -30,6 +30,8 @@
 const SITE = "https://iggy.apache.org";;
 
 export const DIAGRAM_TEXT = {
+  Bench090LatencyChart: `**Chart: latency, 0.9.0 to the next version, 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 with one row per workload and 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, 
p99 and p999, and between table order and bi [...]
+
   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/src/components/bench-0-9-0.tsx b/src/components/bench-0-9-0.tsx
new file mode 100644
index 000000000..9e51dffba
--- /dev/null
+++ b/src/components/bench-0-9-0.tsx
@@ -0,0 +1,401 @@
+/**
+ * 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 one row per workload 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", 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, 20 actors, no rate limit", mbps: [2062.1, 
2096.5], p50: [1.086, 0.792], p99: [26.597, 46.431], p999: [40.869, 78.082] },
+      { workload: "pinned consumer, 20 actors, no rate limit", mbps: [2713.4, 
2532.5], p50: [0.679, 0.619], p99: [6.151, 5.869], p999: [6.476, 23.418] },
+      { 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, 20 actors, no rate limit", mbps: [1134.5, 
1132.5], p50: [4.366, 4.357], p99: [5.159, 5.296], p999: [6.299, 5.929] },
+      { workload: "pinned consumer, 20 actors, no rate limit", mbps: [2710.1, 
2594.3], p50: [0.682, 0.657], p99: [6.12, 5.758], p999: [6.445, 23.362] },
+      { 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;
+}
+
+function formatMs(value: number): string {
+  return value.toLocaleString("en-US", { maximumFractionDigits: 3 });
+}
+
+function formatMbps(value: number): string {
+  return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
+}
+
+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 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 [pinned, setPinned] = useState<string | null>(null);
+  const titleId = useId();
+
+  const setup = findSetup(setupKey);
+  const values = setup.rows.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"
+      ? [...setup.rows].sort((a, b) => deltaPercent(a[percentile]) - 
deltaPercent(b[percentile]))
+      : setup.rows;
+
+  const active = hovered ?? pinned;
+
+  const openHighlight = (h: (typeof HIGHLIGHTS)[number]) => {
+    setSetupKey(h.setup);
+    setPercentile(h.percentile);
+    setPinned(h.workload);
+  };
+
+  const gridColumns = "grid-cols-[minmax(0,8.5rem)_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, 0.9.0 to the next version
+      </h3>
+      <p className="m-0 mt-1 text-xs text-fd-muted-foreground">
+        Milliseconds on a log scale, lower is better. One row per iggy-bench 
workload, both arms on the same host with the same parameters. The tables below 
carry the same numbers.
+      </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="text-[11px] text-fd-muted-foreground">
+          {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: `${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 unthrottled = row.workload.endsWith("no rate limit");
+          const left = Math.min(x(before), x(after));
+          const width = Math.abs(x(after) - x(before));
+          const tooltipLeft = Math.min(Math.max(x(after), 18), 82);
+
+          return (
+            <li key={row.workload} className="m-0 p-0">
+              <button
+                type="button"
+                onMouseEnter={() => setHovered(row.workload)}
+                onMouseLeave={() => setHovered(null)}
+                onFocus={() => setHovered(row.workload)}
+                onBlur={() => setHovered(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 next, 
${formatDelta(pct)}`}
+                className={`grid ${gridColumns} w-full items-center gap-x-3 
rounded-md px-1 py-1 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 ${
+                  isActive ? "bg-fd-muted/40" : ""
+                }`}
+              >
+                <span className="block">
+                  <span className="block text-xs leading-snug 
text-fd-foreground/85">{row.workload}</span>
+                  {unthrottled && (
+                    <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: `${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: `${left}%`, width: `${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: `${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: `${x(after)}%`, backgroundColor: 
SERIES.after.color }}
+                  />
+
+                  {isActive && (
+                    <span
+                      role="tooltip"
+                      className="pointer-events-none absolute bottom-full z-10 
mb-2 w-max max-w-[16rem] -translate-x-1/2 rounded-md border border-fd-border 
bg-fd-popover px-3 py-2 text-left text-xs shadow-lg"
+                      style={{ left: `${tooltipLeft}%` }}
+                    >
+                      <span className="block font-medium 
text-fd-foreground">{row.workload}</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">next, 
{formatMbps(row.mbps[1])} MB/s</span>
+                      </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] text-fd-muted-foreground">
+        Change is the {percentile} latency on the development branch relative 
to 0.9.0. Green is lower, red is higher. 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..d3b972cbd 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -36,6 +36,7 @@ import {
   QuickStartSnippet,
   DocsHero,
 } from "@/components/architecture-diagrams";
+import { Bench090LatencyChart } from "@/components/bench-0-9-0";
 import type { MDXComponents } from "mdx/types";
 
 export function getMDXComponents(components?: MDXComponents): MDXComponents {
@@ -57,6 +58,7 @@ export function getMDXComponents(components?: MDXComponents): 
MDXComponents {
     WhyIggy,
     QuickStartSnippet,
     DocsHero,
+    Bench090LatencyChart,
     ...components,
   };
 }

Reply via email to