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,
   };
 }

Reply via email to