feat(cluster)!: let topics require durable acks and flatten config (#4092)
Producer acknowledgments did not wait for recoverable storage. The
`enforce_fsync` option synchronized segment flushes, but replicas sent
`PrepareOk` before prepares reached stable storage. Consumer-offset
synchronization was one server-wide key.
This PR adds two create-only topic options, `durability` and
`consumer_offset_durability`. Each is `replicated` (the default) or
`persisted`, and neither inherits the other. Both policies write to
disk and complete after VSR quorum commit. Persisted also requires
recoverable stable-storage copies on the quorum, or a local sync in a
single-replica group.
In replicated groups, a persisted policy adds a bounded per-partition
prepare WAL, sized by `partition.wal_bytes_max`. WAL records reference
message bodies in segment files, and hard links keep them until
reclamation. Replicas forward each prepare before their own WAL write
completes. Reclamation waits for durable materialized state, and
missing history or storage errors fail closed.
AWS i4i benchmarks found two costs outside the WAL, which this PR
removes. Produce admission zero-filled each request buffer before the
copy, and repair ran for operations that were already resident.
The configuration drops `[system]`. Its `path` key and remaining
tables move to the root, and `IGGY_SYSTEM_*` variables lose `SYSTEM_`.
This PR removes the stream, topic and partition path keys,
`archive_expired`, `recreate_missing_state` and
`consumer_offset_enforce_fsync`. The server refuses to boot with a
relocated key or a stored topic with `enforce_fsync=true`.
The Rust, Java, C#, Go, Node, Python, PHP and C++ SDKs, the CLI and
the benchmark expose both options. The HTTP `Iggy-Durability` header
reports `replicated` or `persisted` instead of `replicated-memory`.
Poll auto-commit and `ack=none` produce stay asynchronous.
A deterministic simulator storage model injects crashes, power loss
and torn writes into WAL tests. Cluster tests cover crashes and
corruption, and a compatibility test boots a baseline data directory.
The in-memory partition simulator does not run persisted topics.
diff --git a/.github/actions/go/pre-merge/action.yml b/.github/actions/go/pre-merge/action.yml
index 53a381e..1d06e1d 100644
--- a/.github/actions/go/pre-merge/action.yml
+++ b/.github/actions/go/pre-merge/action.yml
@@ -134,7 +134,7 @@
log-file: ${{ runner.temp }}/iggy-go-e2e.log
wait-timeout-seconds: "90"
env:
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-e2e-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-go-e2e-data
- name: Run e2e tests
shell: bash
@@ -190,7 +190,7 @@
log-file: ${{ runner.temp }}/iggy-go-e2e-tls.log
wait-timeout-seconds: "90"
env:
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-e2e-tls-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-go-e2e-tls-data
IGGY_TCP_TLS_ENABLED: "true"
IGGY_TCP_TLS_CERT_FILE: core/certs/iggy_cert.pem
IGGY_TCP_TLS_KEY_FILE: core/certs/iggy_key.pem
@@ -229,7 +229,7 @@
wait-timeout-seconds: "90"
env:
IGGY_CLUSTER_ENABLED: "true"
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-cluster-0-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-go-cluster-0-data
- name: Start Iggy VSR cluster node 1
id: iggy-cluster-1
@@ -245,7 +245,7 @@
wait-timeout-seconds: "90"
env:
IGGY_CLUSTER_ENABLED: "true"
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-cluster-1-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-go-cluster-1-data
- name: Run cluster e2e tests
shell: bash
diff --git a/.github/actions/java-gradle/pre-merge/action.yml b/.github/actions/java-gradle/pre-merge/action.yml
index d556888..db377bd 100644
--- a/.github/actions/java-gradle/pre-merge/action.yml
+++ b/.github/actions/java-gradle/pre-merge/action.yml
@@ -88,7 +88,7 @@
cargo-bin: iggy-server
wait-timeout-seconds: "90"
env:
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-java-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-java-data
- name: Test
if: inputs.task == 'test'
@@ -166,7 +166,7 @@
pid-file: ${{ runner.temp }}/iggy-server-tls.pid
log-file: ${{ runner.temp }}/iggy-server-tls.log
env:
- IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-java-tls-data
+ IGGY_PATH: ${{ runner.temp }}/iggy-java-tls-data
IGGY_TCP_TLS_ENABLED: "true"
IGGY_TCP_TLS_CERT_FILE: core/certs/iggy_cert.pem
IGGY_TCP_TLS_KEY_FILE: core/certs/iggy_key.pem
diff --git a/Cargo.lock b/Cargo.lock
index 4f18e57..007cf38 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6811,7 +6811,7 @@
[[package]]
name = "iggy"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
dependencies = [
"async-broadcast",
"async-dropper",
@@ -6845,7 +6845,7 @@
[[package]]
name = "iggy-bench"
-version = "0.6.0-edge.7"
+version = "0.6.0-edge.8"
dependencies = [
"async-trait",
"bench-report",
@@ -6902,7 +6902,7 @@
[[package]]
name = "iggy-cli"
-version = "0.14.0-edge.7"
+version = "0.14.0-edge.8"
dependencies = [
"anyhow",
"apple-native-keyring-store",
@@ -7008,7 +7008,7 @@
[[package]]
name = "iggy-mcp"
-version = "0.5.0-edge.6"
+version = "0.5.0-edge.7"
dependencies = [
"axum",
"axum-server",
@@ -7042,7 +7042,7 @@
[[package]]
name = "iggy_binary_protocol"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
dependencies = [
"aligned-vec",
"bytemuck",
@@ -7055,7 +7055,7 @@
[[package]]
name = "iggy_common"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
dependencies = [
"aes-gcm 0.11.1",
"async-broadcast",
@@ -7440,7 +7440,7 @@
[[package]]
name = "iggy_connector_sdk"
-version = "0.4.0-edge.4"
+version = "0.4.0-edge.5"
dependencies = [
"anyhow",
"apache-avro 0.22.0",
@@ -7999,6 +7999,8 @@
"compio",
"futures",
"iggy_binary_protocol",
+ "iggy_common",
+ "nix",
"server_common",
"tempfile",
"tracing",
@@ -12635,7 +12637,7 @@
[[package]]
name = "server"
-version = "0.9.0-edge.7"
+version = "0.9.0-edge.8"
dependencies = [
"ahash 0.8.12",
"argon2",
@@ -12976,6 +12978,7 @@
"tempfile",
"tracing",
"tracing-subscriber",
+ "twox-hash",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index b84d880..cb03ebd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -199,13 +199,13 @@
iceberg = "0.10.1"
iceberg-catalog-rest = "0.10.1"
iceberg-storage-opendal = "0.10.1"
-iggy = { path = "core/sdk", version = "0.11.0-edge.7" }
-iggy-cli = { path = "core/cli", version = "0.14.0-edge.7" }
+iggy = { path = "core/sdk", version = "0.11.0-edge.8" }
+iggy-cli = { path = "core/cli", version = "0.14.0-edge.8" }
iggy-gateway-kafka = { path = "gateways/kafka" }
-iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.0-edge.7" }
-iggy_common = { path = "core/common", version = "0.11.0-edge.7" }
+iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.0-edge.8" }
+iggy_common = { path = "core/common", version = "0.11.0-edge.8" }
iggy_connector_doris_sink = { path = "core/connectors/sinks/doris_sink" }
-iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.4.0-edge.4" }
+iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.4.0-edge.5" }
indexmap = "2.14.1"
integration = { path = "core/integration" }
ipnet = "2.12.1"
diff --git a/README.md b/README.md
index 3b00daf..e78873f 100644
--- a/README.md
+++ b/README.md
@@ -231,6 +231,18 @@
When config file is not found, the default values from embedded `config.toml` file are used.
+Topic creation accepts two independent policies: `durability` for message acknowledgments and `consumer_offset_durability` for explicit offset stores and deletes. Both default to `replicated`. This means VSR quorum commit without waiting for stable storage. `persisted` also requires recoverable stable-storage copies on the replication quorum. Both policies normally store data on disk. Poll auto-commit remains asynchronous and is not covered by the poll response's completion.
+
+The data directory is configured with `path` or `IGGY_PATH`. The layout beneath it is `streams/<stream>/topics/<topic>/partitions/<partition>`, with fixed directory names.
+
+The HTTP `Iggy-Durability` header reports `replicated` or `persisted` for awaited writes, and `none` for early dispatch acceptance.
+
+Segment flush thresholds control scheduling, independently of acknowledgment durability.
+
+Rust HTTP callers can use `HttpClient::send_messages_with_durability` to read the advertised guarantee alongside confirmations.
+
+The CLI exposes `--durability persisted` and `--consumer-offset-durability persisted` on `topic create`. Select either independently. The policy names describe completion guarantees and do not prescribe an I/O syscall.
+
For the detailed documentation of the configuration file, please refer to the [configuration](https://iggy.apache.org/docs/server/configuration) section.
---
@@ -271,7 +283,7 @@
`cargo run --bin iggy-server`
-All the data used by the server will be persisted under the `local_data` directory by default, unless specified differently in the configuration (see `system.path` in `config.toml`).
+All the data used by the server will be persisted under the `local_data` directory by default, unless specified differently in the configuration (see `path` in `config.toml`).
One can use default root credentials with optional `--with-default-root-credentials`.
This flag is equivalent to setting `IGGY_ROOT_USERNAME=iggy` and `IGGY_ROOT_PASSWORD=iggy`, plus
@@ -291,7 +303,7 @@
`IGGY_TCP_ADDRESS=127.0.0.1:8090 cargo run --bin iggy-server`
- Set custom data path
- `IGGY_SYSTEM_PATH=/data/iggy cargo run --bin iggy-server`
+ `IGGY_PATH=/data/iggy cargo run --bin iggy-server`
- Enable HTTP transport
`IGGY_HTTP_ENABLED=true cargo run --bin iggy-server`
@@ -430,7 +442,7 @@
cargo build --release
```
-Then, run the benchmarking app with the desired options:
+Start `iggy-server` separately, then run the benchmarking app with the desired options:
1. Sending (writing) benchmark
@@ -474,14 +486,26 @@
cargo run --bin iggy-bench -r -- end-to-end-producing-consumer tcp
```
-These benchmarks would start the server with the default configuration, create a stream, topic and partition, and then send or poll the messages. The default configuration is optimized for the best performance, so you might want to tweak it for your needs. If you need more options, please refer to `iggy-bench` subcommands `help` and `examples`.
+8. End to end producing and consuming through a consumer group:
-For example, to run the benchmark for the already started server, provide the additional argument `--server-address 0.0.0.0:8090`.
+ ```bash
+ cargo run --bin iggy-bench -r -- end-to-end-producing-consumer-group tcp
+ ```
+
+The benchmark connects to a running server and creates the streams, topics, and partitions needed by the selected workload. Use `iggy-bench --help` and `iggy-bench examples` for all benchmark variants, transports, and topic-option examples. Both message and consumer-offset durability independently default to `replicated`.
+
+For example, to run the benchmark for the already started server, provide the additional argument `--server-address 127.0.0.1:8090`.
**Iggy is already capable of processing millions of messages per second at the microseconds range for p99+ latency** Depending on the hardware, transport protocol (`quic`, `websocket`, `tcp` or `http`) and payload size (`messages-per-batch * message-size`) you might expect **over 5000 MB/s (e.g. 5M of 1 KB msg/sec) throughput for writes and reads**.
Please refer to the mentioned [benchmarking platform](https://benchmarks.iggy.apache.org) where you can browse the results achieved on the different hardware configurations, using the different Iggy server versions.
+### Host preparation
+
+Check `io_uring` access, process limits, memory headroom, CPU/NUMA placement, and sustained disk/network capacity before comparing runs. Measure host-tuning changes with the same workload and durability policies.
+
+Use the [benchmark host checklist](core/bench/README.md#host-preparation) for practical setup and repeatable measurements. The [Linux tuning guide](https://iggy.apache.org/docs/server/linux-tuning) explains swappiness, huge pages, writeback, CPU placement, and networking, with commands and upstream references.
+
---
## Contributing
diff --git a/bdd/docker-compose.cluster.yml b/bdd/docker-compose.cluster.yml
index df773b8..d84da34 100644
--- a/bdd/docker-compose.cluster.yml
+++ b/bdd/docker-compose.cluster.yml
@@ -105,7 +105,7 @@
environment:
<<: *cluster-topology
RUST_LOG: info
- IGGY_SYSTEM_PATH: local_data_leader
+ IGGY_PATH: local_data_leader
IGGY_TCP_ADDRESS: 0.0.0.0:8091
IGGY_HTTP_ADDRESS: 0.0.0.0:3001
IGGY_QUIC_ADDRESS: 0.0.0.0:8081
@@ -131,7 +131,7 @@
environment:
<<: *cluster-topology
RUST_LOG: info
- IGGY_SYSTEM_PATH: local_data_follower
+ IGGY_PATH: local_data_follower
IGGY_TCP_ADDRESS: 0.0.0.0:8092
IGGY_HTTP_ADDRESS: 0.0.0.0:3002
IGGY_QUIC_ADDRESS: 0.0.0.0:8082
diff --git a/bdd/docker-compose.server.yml b/bdd/docker-compose.server.yml
index c57b3cd..88868ac 100644
--- a/bdd/docker-compose.server.yml
+++ b/bdd/docker-compose.server.yml
@@ -63,7 +63,7 @@
- RUST_LOG=info
- IGGY_ROOT_USERNAME=iggy
- IGGY_ROOT_PASSWORD=iggy
- - IGGY_SYSTEM_PATH=local_data
+ - IGGY_PATH=local_data
- IGGY_TCP_ADDRESS=0.0.0.0:8090
- IGGY_NODE_ADVERTISED_ADDRESS=iggy-server
- IGGY_HTTP_ADDRESS=0.0.0.0:3000
diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock
index fad1a3f..2f3e7d0 100644
--- a/bdd/python/uv.lock
+++ b/bdd/python/uv.lock
@@ -8,7 +8,7 @@
[[package]]
name = "apache-iggy"
-version = "0.9.0.dev7"
+version = "0.9.0.dev8"
source = { directory = "../../foreign/python" }
[package.metadata]
diff --git a/core/ai/mcp/Cargo.toml b/core/ai/mcp/Cargo.toml
index 1d1953f..688c650 100644
--- a/core/ai/mcp/Cargo.toml
+++ b/core/ai/mcp/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy-mcp"
-version = "0.5.0-edge.6"
+version = "0.5.0-edge.7"
description = "MCP Server for Iggy message streaming platform"
edition = "2024"
license = "Apache-2.0"
diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml
index 7a3f1ce..8dc2f65 100644
--- a/core/bench/Cargo.toml
+++ b/core/bench/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy-bench"
-version = "0.6.0-edge.7"
+version = "0.6.0-edge.8"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/apache/iggy"
diff --git a/core/bench/README.md b/core/bench/README.md
index b3ae1ac..66d1ab5 100644
--- a/core/bench/README.md
+++ b/core/bench/README.md
@@ -5,3 +5,63 @@
Iggy Bench CLI can be installed with `cargo install iggy-bench` and then simply accessed by typing `iggy-bench` in your terminal.

+
+The WebSocket transport command is `websocket`, with `ws` as a shorthand.
+
+Producer and consumer counts default to six. Pinned workloads also default to six streams. Override the counts for the workload and available CPUs.
+
+## Examples and topic options
+
+Start Iggy before running benchmarks. The tool connects to an existing server. Run `iggy-bench examples` for all eight benchmark kinds, their aliases, all four transports, and topic-option combinations. Global options precede the benchmark kind, kind-specific options follow it, and the server address follows the transport.
+
+Both `--durability` and `--consumer-offset-durability` independently default to `replicated`. The following runs use the same workload with different topic policies:
+
+```bash
+# Both policies replicated, the default.
+iggy-bench balanced-producer-and-consumer-group tcp
+
+# Persisted message acknowledgments with replicated offsets.
+iggy-bench --durability persisted balanced-producer-and-consumer-group tcp
+
+# Replicated message acknowledgments with persisted offsets.
+iggy-bench --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp
+
+# Both policies persisted.
+iggy-bench --durability persisted --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp
+```
+
+These options apply when the benchmark creates topics. They do not change existing topics with `--reuse-streams`. Both policies normally write data to disk. `persisted` adds a stable-storage completion requirement. Consumer workloads using poll auto-commit still receive asynchronous poll responses, so this flag does not turn their poll latency into a measurement of acknowledged offset-store latency.
+
+`--messages-required-to-save` controls a topic's segment-flush cadence independently of durability. `--max-topic-size` and `--message-expiry` are kind-specific topic options. Use the selected kind's `--help` to inspect supported flags. OS writeback settings do not replace these policies.
+
+## Host preparation
+
+Use a dedicated server and a separate load generator where possible. If they share a host, assign disjoint CPU sets and leave CPU capacity for kernel and network work. Match the deployment's CPU topology, storage, replication-group size, and durability policies.
+
+| Area | Starting point | What to check |
+| --- | --- | --- |
+| Runtime access | Permit the service to create `io_uring` instances. | Startup diagnostics, container syscall policy, and effective process limits. |
+| File descriptors and locked memory | Size limits for connections, partitions, and runtime allocations. | `/proc/PID/limits`, not just the shell's `ulimit`. Set systemd service limits explicitly. |
+| Swap | With enough RAM and disk-backed swap, compare `vm.swappiness=10` against the existing value. | Swap activity, memory pressure, and p99 latency. This does not disable swap. |
+| Huge pages | Start without an explicit reservation, then test a budgeted pool with a compatible allocator. | Actual page size, usage, NUMA placement, and memory left for page cache. |
+| CPU placement | Use the server's `[sharding]` options within its allowed CPU set. | Per-core load, CPU steal time, cgroup throttling, and IRQ distribution. |
+| Writeback | Start with the OS defaults. | Sustained disk latency and dirty-page buildup. Large percentage limits can mask short-run bottlenecks. |
+| Networking | Start with the OS defaults. | Receive drops, retransmissions, connection backlogs, and bandwidth-delay requirements before increasing limits. |
+
+Read the [Linux tuning guide](https://iggy.apache.org/docs/server/linux-tuning) before reserving huge pages. `vm.nr_hugepages=2048` reserves 4 GiB only with 2 MiB pages. **`MIMALLOC_RESERVE_HUGE_OS_PAGES=2048` requests 2048 one-GiB pages, or 2 TiB.** They are different controls. The guide explains allocator configuration, THP, service limits, permissions, and reboot persistence.
+
+### Repeatable measurements
+
+1. Record the server and benchmark revisions, build profile, kernel, VM type, CPU allocation, allocator, memory limits, filesystem, and provisioned disk/network capacity.
+2. Keep workload shape, both durability policies, replication-group size, and topic options identical between compared runs. Use an explicit warmup and repeat runs.
+3. Observe `vmstat 1`, `iostat -xz 1`, `mpstat -P ALL 1`, memory/I/O pressure, and network errors. Confirm the client has spare CPU and network capacity.
+4. Run long enough to include segment flushes, checkpoints, and sustained device limits. Distinguish warm-cache tests, cold-cache tests, and storage throughput. Do not drop caches during a production workload.
+5. Change one host setting at a time, retain its previous value, and compare throughput, p50/p99/p99.9 latency, memory, and errors. Record successful changes in provisioning and recheck after reboot.
+
+The `output` subcommand records results and accepts context for the run:
+
+```bash
+iggy-bench --warmup-time 10s --total-data 10GiB balanced-producer tcp output --identifier baseline --remark replicated-defaults
+```
+
+Choose `--total-data` for the duration and storage behavior being tested. This example is not a universal sufficient data volume.
diff --git a/core/bench/dashboard/frontend/index.html b/core/bench/dashboard/frontend/index.html
index 92fd763..5d3b066 100644
--- a/core/bench/dashboard/frontend/index.html
+++ b/core/bench/dashboard/frontend/index.html
@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
diff --git a/core/bench/src/args/common.rs b/core/bench/src/args/common.rs
index c11a32a..da92b2e 100644
--- a/core/bench/src/args/common.rs
+++ b/core/bench/src/args/common.rs
@@ -101,15 +101,15 @@
#[arg(long, default_value_t = false)]
pub reuse_streams: bool,
- /// Fsync each journal flush on the benchmark topic. Flush timing stays
- /// governed by `--messages-required-to-save` (server default: 1024), so
- /// acks are durability-gated only with `--messages-required-to-save 1`.
- /// Topic option at creation, so it has no effect with `--reuse-streams`.
- #[arg(long, default_value_t = false)]
- pub enforce_fsync: bool,
+ /// Message completion policy for newly created benchmark topics.
+ #[arg(long, default_value_t = iggy::prelude::Durability::Replicated)]
+ pub durability: iggy::prelude::Durability,
+
+ /// Offset completion policy, independent of message durability.
+ #[arg(long, default_value_t = iggy::prelude::Durability::Replicated)]
+ pub consumer_offset_durability: iggy::prelude::Durability,
/// Topic journal flush threshold in messages (server default: 1024).
- /// With `--enforce-fsync`, `1` makes every produce ack wait for the fsync.
/// Topic option at creation, so it has no effect with `--reuse-streams`.
#[arg(long)]
pub messages_required_to_save: Option<NonZeroU32>,
@@ -161,7 +161,7 @@
Self::command()
.error(
ErrorKind::ArgumentConflict,
- "--total-messages-size must be at least 2x greater than --message-size",
+ "--total-data must be at least twice --message-size",
)
.exit();
}
@@ -340,8 +340,12 @@
self.reuse_streams
}
- pub const fn enforce_fsync(&self) -> bool {
- self.enforce_fsync
+ pub const fn durability(&self) -> iggy::prelude::Durability {
+ self.durability
+ }
+
+ pub const fn consumer_offset_durability(&self) -> iggy::prelude::Durability {
+ self.consumer_offset_durability
}
pub const fn messages_required_to_save(&self) -> Option<NonZeroU32> {
@@ -374,12 +378,7 @@
BenchmarkKindCommand::Examples => unreachable!(),
};
- let transport = match self.transport_command() {
- BenchmarkTransportCommand::Tcp(_) => "tcp",
- BenchmarkTransportCommand::Quic(_) => "quic",
- BenchmarkTransportCommand::Http(_) => "http",
- BenchmarkTransportCommand::WebSocket(_) => "ws",
- };
+ let transport = self.transport_command().as_str();
let actors = match &self.benchmark_kind {
BenchmarkKindCommand::PinnedProducer(_)
diff --git a/core/bench/src/args/defaults.rs b/core/bench/src/args/defaults.rs
index a5f4dfa..f860a91 100644
--- a/core/bench/src/args/defaults.rs
+++ b/core/bench/src/args/defaults.rs
@@ -35,15 +35,15 @@
pub const DEFAULT_MESSAGE_SIZE: NonZeroU32 = u32!(1000);
pub const DEFAULT_TOTAL_MESSAGES_SIZE: IggyByteSize = IggyByteSize::new(8_000_000);
-pub const DEFAULT_PINNED_NUMBER_OF_STREAMS: NonZeroU32 = u32!(8);
+pub const DEFAULT_PINNED_NUMBER_OF_STREAMS: NonZeroU32 = u32!(6);
pub const DEFAULT_BALANCED_NUMBER_OF_STREAMS: NonZeroU32 = u32!(1);
pub const DEFAULT_PINNED_NUMBER_OF_PARTITIONS: NonZeroU32 = u32!(1);
pub const DEFAULT_BALANCED_NUMBER_OF_PARTITIONS: NonZeroU32 = u32!(24);
-pub const DEFAULT_NUMBER_OF_CONSUMERS: NonZeroU32 = u32!(8);
+pub const DEFAULT_NUMBER_OF_CONSUMERS: NonZeroU32 = u32!(6);
pub const DEFAULT_NUMBER_OF_CONSUMER_GROUPS: NonZeroU32 = u32!(1);
-pub const DEFAULT_NUMBER_OF_PRODUCERS: NonZeroU32 = u32!(8);
+pub const DEFAULT_NUMBER_OF_PRODUCERS: NonZeroU32 = u32!(6);
pub const DEFAULT_WARMUP_TIME: &str = "0s";
diff --git a/core/bench/src/args/examples.rs b/core/bench/src/args/examples.rs
index 8602085..7cb33b6 100644
--- a/core/bench/src/args/examples.rs
+++ b/core/bench/src/args/examples.rs
@@ -15,176 +15,223 @@
// specific language governing permissions and limitations
// under the License.
-const EXAMPLES: &str = r#"EXAMPLES:
+const EXAMPLES: &str = r"EXAMPLES:
-1) Pinned Mode Benchmarking:
+Start iggy-server separately. The benchmark connects to a running server.
+Global options precede the kind, kind options precede the transport, and
+transport options precede the optional output subcommand.
- Run benchmarks with pinned producers and consumers. This mode pins specific producers
- and consumers to specific streams and partitions (one to one):
+Default producer and consumer counts are six. Pinned workloads default to six streams.
+
+1) All benchmark kinds and aliases:
+
+ Pinned producer (pp), consumer (pc), and producer/consumer (ppc):
$ cargo r -r --bin iggy-bench -- pinned-producer --streams 10 --producers 10 tcp
$ cargo r -r --bin iggy-bench -- pinned-consumer --streams 10 --consumers 10 tcp
$ cargo r -r --bin iggy-bench -- pinned-producer-and-consumer --streams 10 --producers 10 --consumers 10 tcp
- $ cargo r -r --bin iggy-bench -- -T 10GB pp --producers 5 tcp
-2) Balanced Mode Benchmarking:
-
- Run benchmarks with balanced distribution of producers and consumers. This mode
- automatically balances the load across streams and consumer groups:
+ Balanced producer (bp), consumer group (bcg), and producer/consumer group (bpcg):
$ cargo r -r --bin iggy-bench -- balanced-producer --partitions 24 --producers 6 tcp
$ cargo r -r --bin iggy-bench -- balanced-consumer-group --consumers 6 tcp
$ cargo r -r --bin iggy-bench -- balanced-producer-and-consumer-group --partitions 24 --producers 6 --consumers 6 tcp
- $ cargo r -r --bin iggy-bench -- -T 10GB bpc tcp
+ $ cargo r -r --bin iggy-bench -- --total-data 10GiB bpcg tcp
- Durability-matched run, where every produce ack waits for an fsync
- (--partitions 1 routes every producer straight to that partition):
+ End-to-end producing consumer (e2e) and producing consumer group (e2ecg):
- $ cargo r -r --bin iggy-bench -- --enforce-fsync --messages-required-to-save 1 \
- balanced-producer --partitions 1 --producers 8 tcp
+ $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer --producing-consumers 12 --streams 12 tcp
+ $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer-group --partitions 24 --producers 6 --consumers 6 tcp
-3) End-to-End Benchmarking:
+2) All transports:
- Run end-to-end benchmarks that measure performance for a producer that is also a consumer:
+ $ cargo r -r --bin iggy-bench -- pinned-producer tcp --server-address 127.0.0.1:8090
+ $ cargo r -r --bin iggy-bench -- pinned-producer quic --server-address 127.0.0.1:8080
+ $ cargo r -r --bin iggy-bench -- pinned-producer http --server-address 127.0.0.1:3000
+ $ cargo r -r --bin iggy-bench -- pinned-producer websocket --server-address 127.0.0.1:8092
- $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer --producers 12 --streams 12 tcp
- $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer-group --partitions 24 --producers 6 tcp
+3) Topic durability:
-4) Advanced Configuration:
+ --durability controls message completion.
+ --consumer-offset-durability controls explicit offset-store/delete completion.
+ Both independently default to replicated. Neither inherits the other.
+ Both policies normally write to disk. Persisted additionally waits for
+ recoverable stable storage on the required VSR quorum, or the single replica.
- You can customize various parameters for any benchmark mode:
+ Both replicated (the default):
+ $ cargo r -r --bin iggy-bench -- balanced-producer-and-consumer-group tcp
- Global options (before the benchmark command):
- --messages-per-batch (-P): Number of messages per batch [default: 1000]
- For random batch sizes, use range format: "100..1000"
- --message-batches (-b): Total number of batches [default: 1000]
- --total-messages-size (-T): Total size of messages to send (e.g., "1GB", "500MB")
- Mutually exclusive with --message-batches
- --message-size (-m): Message size in bytes [default: 1000]
- For random sizes, use range format: "100..1000"
- --rate-limit (-r): Optional throughput limit per producer (e.g., "50KB", "10MB")
- --warmup-time (-w): Warmup duration [default: 0s]
- --sampling-time (-t): Metrics sampling interval [default: 10ms]
- --moving-average-window (-W): Window size for moving average [default: 20]
- --username (-u): Username for server authentication [default: iggy]
- --password (-p): Password for server authentication [default: iggy]
- --reuse-streams: Reuse existing bench streams instead of deleting them
+ Persisted messages, replicated offsets:
+ $ cargo r -r --bin iggy-bench -- --durability persisted balanced-producer-and-consumer-group tcp
- Benchmark-specific options (after the benchmark command):
- --streams (-s): Number of streams
- --partitions (-a): Number of partitions
- --producers (-c): Number of producers
- --consumers (-c): Number of consumers
- --max-topic-size (-T): Max topic size (e.g., "1GiB")
- --message-expiry (-e): Topic message expiry time (e.g., "1s", "5min", "1h")
+ Replicated messages, persisted offsets:
+ $ cargo r -r --bin iggy-bench -- --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp
- Examples with detailed configuration:
+ Both persisted:
+ $ cargo r -r --bin iggy-bench -- --durability persisted --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp
- # Fixed message and batch sizes:
- $ cargo r -r --bin iggy-bench -- \
- --message-size 1000 \
- --messages-per-batch 100 \
- --message-batches 1000 \
- --rate-limit "100MB" \
- balanced-producer \
- --streams 5 \
- --producers 5 \
- tcp
+ These options apply when topics are created. They do not modify topics
+ with --reuse-streams. Consumer polling with auto-commit remains asynchronous.
+ Its poll latency is not an acknowledged offset-store latency measurement.
+ In replicated groups, either persisted policy enables a WAL that also retains
+ message predecessors, even when message durability is replicated.
- # Random message sizes (100-1000 bytes):
- $ cargo r -r --bin iggy-bench -- \
- --message-size "100..1000" \
- --messages-per-batch 100 \
- --total-messages-size "1GB" \
- balanced-producer \
- --streams 5 \
- --producers 5 \
- tcp
+4) Topic flush cadence and retention:
- # Random batch sizes (10-100 messages per batch):
- $ cargo r -r --bin iggy-bench -- \
- --message-size 1000 \
- --messages-per-batch "10..100" \
- --total-messages-size "500MB" \
- balanced-producer \
- --streams 5 \
- --producers 5 \
- tcp
+ --messages-required-to-save is a global create-time topic option.
+ It controls segment flush cadence, not the acknowledgment guarantee.
+ --max-topic-size and --message-expiry are kind-specific topic options.
+ These flags do not change existing topics with --reuse-streams.
- # Random message and batch sizes with rate limiting:
- $ cargo r -r --bin iggy-bench -- \
- --message-size "500..2000" \
- --messages-per-batch "50..200" \
- --total-messages-size "2GB" \
- --rate-limit "50MB" \
- balanced-producer \
- --streams 5 \
- --producers 5 \
- tcp
+ Persisted messages without forcing a segment flush for every message:
+ $ cargo r -r --bin iggy-bench -- --durability persisted --messages-required-to-save 1024 balanced-producer --partitions 1 --producers 8 tcp
-5) Remote Server Benchmarking:
+ Replicated acknowledgments with eager segment flushing:
+ $ cargo r -r --bin iggy-bench -- --messages-required-to-save 1 balanced-producer tcp
- To benchmark a remote server, specify the server address in the transport subcommand.
- Both IP addresses and hostnames are supported:
+ Retention can delete data during a long run. Use matching policies when comparing:
+ $ cargo r -r --bin iggy-bench -- balanced-producer --max-topic-size 10GiB --message-expiry 1h tcp
- $ cargo r -r --bin iggy-bench -- pinned-producer \
- --streams 5 --producers 5 \
- tcp --server-address 192.168.1.100:8090
- $ cargo r -r --bin iggy-bench -- pinned-producer \
- --streams 5 --producers 5 \
- tcp --server-address localhost:8090
+5) Workload configuration:
- With custom credentials:
+ --messages-per-batch (-P): Messages per batch, or a range such as 100..1000.
+ --message-batches (-b): Batches per actor, mutually exclusive with --total-data.
+ --total-data (-T): Total message bytes across actors, such as 10GiB.
+ --message-size (-m): Message bytes, or a range such as 100..1000.
+ --rate-limit (-r): Aggregate throughput limit across actors, such as 100MB.
+ --warmup-time (-w): Warmup duration, such as 10s.
+ --sampling-time (-t): Metrics sampling interval.
+ --moving-average-window (-W): Moving-average window size.
- $ cargo r -r --bin iggy-bench -- \
- --username admin --password secret \
- pinned-producer --streams 5 --producers 5 \
- tcp --server-address 192.168.1.100:8090
+ Fixed message and batch sizes:
+ $ cargo r -r --bin iggy-bench -- --message-size 1000 --messages-per-batch 100 --message-batches 1000 --rate-limit 100MB balanced-producer --streams 5 --producers 5 tcp
-6) Output Data and Results:
+ Random message sizes:
+ $ cargo r -r --bin iggy-bench -- --message-size 100..1000 --messages-per-batch 100 --total-data 1GiB balanced-producer --streams 5 --producers 5 tcp
- The benchmark tool can store detailed results for analysis and comparison:
+ Random batch sizes:
+ $ cargo r -r --bin iggy-bench -- --message-size 1000 --messages-per-batch 10..100 --total-data 500MiB balanced-producer tcp
- # Basic result storage (results will be stored in ./performance_results):
- $ cargo r -r --bin iggy-bench -- pinned-producer --streams 10 --producers 10 tcp output
+ Random message and batch sizes with a warmup and aggregate rate limit:
+ $ cargo r -r --bin iggy-bench -- --message-size 500..2000 --messages-per-batch 50..200 --total-data 2GiB --warmup-time 10s --rate-limit 50MB balanced-producer tcp
+6) Remote server and output:
- # Organized benchmarking with metadata:
- $ cargo r -r --bin iggy-bench -- balanced-producer --partitions 24 --producers 6 tcp \
- output \
- --identifier "prod-test-$(date +%Y%m%d)" \
- --remark "production-config" \
- --gitref "$(git rev-parse --short HEAD)" \
- --gitref-date "$(git show -s --format=%cI HEAD)"
+ $ cargo r -r --bin iggy-bench -- pinned-producer --streams 5 --producers 5 tcp --server-address localhost:8090
+ $ cargo r -r --bin iggy-bench -- --username admin --password secret pinned-producer tcp --server-address 192.168.1.100:8090
+ $ cargo r -r --bin iggy-bench -- pinned-producer tcp output
+ $ cargo r -r --bin iggy-bench -- --durability persisted balanced-producer tcp output --identifier dedicated-host --remark persisted-messages --gitref abc123
+ $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer tcp output --open-charts
- # Quick result visualization:
- $ cargo r -r --bin iggy-bench -- end-to-end-producing-consumer --producers 12 --streams 12 tcp \
- output --open-charts
+ Output options include --output-dir (-o), --identifier, --remark, --gitref,
+ --gitref-date, --extra-info, and --open-charts (-c).
+ Record both durability policies, CPU allocation, host tuning, and storage.
+ See core/bench/README.md and https://iggy.apache.org/docs/server/linux-tuning.
- Output configuration options:
- --open-charts (-c) : Open charts after the benchmark
- --output-dir (-o) : Directory for storing results [default: performance_results]
- --identifier : Benchmark run ID (if not provided defaults to hostname)
- --remark : Additional context (e.g., "production-config")
- --extra-info : Custom metadata for future analysis, currently unused
+7) Help:
-7) Help and Documentation:
-
- For more details on available options:
-
- # General help
$ cargo r -r --bin iggy-bench -- --help
-
- # Specific benchmark help
$ cargo r -r --bin iggy-bench -- pinned-producer --help
-
- # Protocol help
$ cargo r -r --bin iggy-bench -- pinned-producer tcp --help
-
- # Output help
$ cargo r -r --bin iggy-bench -- pinned-producer tcp output --help
-"#;
+";
pub fn print_examples() {
println!("{EXAMPLES}");
}
+
+#[cfg(test)]
+mod tests {
+ use super::EXAMPLES;
+ use crate::args::common::IggyBenchArgs;
+ use clap::{CommandFactory, FromArgMatches, Parser, error::ErrorKind};
+ use iggy::prelude::Durability;
+ use std::collections::BTreeSet;
+
+ #[test]
+ fn published_examples_parse_and_cover_every_kind_and_transport() {
+ let mut kinds = BTreeSet::new();
+ let mut transports = BTreeSet::new();
+ for command in EXAMPLES.lines().filter_map(|line| {
+ line.trim()
+ .strip_prefix("$ cargo r -r --bin iggy-bench -- ")
+ }) {
+ let arguments = std::iter::once("iggy-bench").chain(command.split_ascii_whitespace());
+ match IggyBenchArgs::command().try_get_matches_from(arguments) {
+ Ok(matches) => {
+ let (kind, options) = matches.subcommand().unwrap();
+ kinds.insert(kind.to_owned());
+ transports.insert(options.subcommand_name().unwrap().to_owned());
+ IggyBenchArgs::from_arg_matches(&matches)
+ .unwrap()
+ .validate();
+ }
+ Err(error) => {
+ assert_eq!(error.kind(), ErrorKind::DisplayHelp, "{command}: {error}");
+ }
+ }
+ }
+ let command = IggyBenchArgs::command();
+ for kind in command
+ .get_subcommands()
+ .filter(|kind| kind.get_name() != "examples")
+ {
+ assert!(
+ kinds.contains(kind.get_name()),
+ "missing kind {}",
+ kind.get_name()
+ );
+ }
+ let producer = command.find_subcommand("pinned-producer").unwrap();
+ for transport in producer.get_subcommands() {
+ assert!(
+ transports.contains(transport.get_name()),
+ "missing transport {}",
+ transport.get_name()
+ );
+ }
+ }
+
+ #[test]
+ fn websocket_and_its_alias_use_the_same_canonical_name() {
+ for name in ["websocket", "ws"] {
+ let parsed =
+ IggyBenchArgs::try_parse_from(["iggy-bench", "pinned-producer", name]).unwrap();
+ assert_eq!(parsed.transport_command().as_str(), "websocket");
+ }
+ }
+
+ #[test]
+ fn durability_defaults_are_independent() {
+ for (flags, messages, offsets) in [
+ (vec![], Durability::Replicated, Durability::Replicated),
+ (
+ vec!["--durability", "persisted"],
+ Durability::Persisted,
+ Durability::Replicated,
+ ),
+ (
+ vec!["--consumer-offset-durability", "persisted"],
+ Durability::Replicated,
+ Durability::Persisted,
+ ),
+ (
+ vec![
+ "--durability",
+ "persisted",
+ "--consumer-offset-durability",
+ "persisted",
+ ],
+ Durability::Persisted,
+ Durability::Persisted,
+ ),
+ ] {
+ let arguments = std::iter::once("iggy-bench")
+ .chain(flags)
+ .chain(["balanced-producer-and-consumer-group", "tcp"]);
+ let parsed = IggyBenchArgs::try_parse_from(arguments).unwrap();
+ assert_eq!(parsed.durability, messages);
+ assert_eq!(parsed.consumer_offset_durability, offsets);
+ }
+ }
+}
diff --git a/core/bench/src/args/kinds/balanced/producer_and_consumer_group.rs b/core/bench/src/args/kinds/balanced/producer_and_consumer_group.rs
index add19b4..cb2d7e1 100644
--- a/core/bench/src/args/kinds/balanced/producer_and_consumer_group.rs
+++ b/core/bench/src/args/kinds/balanced/producer_and_consumer_group.rs
@@ -107,7 +107,7 @@
if cg_number < streams {
cmd.error(
ErrorKind::ArgumentConflict,
- "Consumer groups number must be less than or equal to the number of streams.",
+ "Consumer groups number must be greater than or equal to the number of streams.",
)
.exit();
}
@@ -118,7 +118,7 @@
if partitions < consumers {
cmd.error(
ErrorKind::ArgumentConflict,
- "Consumer number must be greater than the number of partitions.",
+ "Consumer number must be less than or equal to the number of partitions.",
)
.exit();
}
diff --git a/core/bench/src/args/kinds/end_to_end/producing_consumer_group.rs b/core/bench/src/args/kinds/end_to_end/producing_consumer_group.rs
index f67d550..bf935ba 100644
--- a/core/bench/src/args/kinds/end_to_end/producing_consumer_group.rs
+++ b/core/bench/src/args/kinds/end_to_end/producing_consumer_group.rs
@@ -113,7 +113,7 @@
cmd.error(
ErrorKind::ArgumentConflict,
format!(
- "For producing consumer group benchmark, consumer groups number ({cg_number}) must be less than the number of streams ({streams})"
+ "For producing consumer group benchmark, consumer groups number ({cg_number}) must be greater than or equal to the number of streams ({streams})"
),
)
.exit();
diff --git a/core/bench/src/args/kinds/pinned/consumer.rs b/core/bench/src/args/kinds/pinned/consumer.rs
index 1fcabf7..155d8cc 100644
--- a/core/bench/src/args/kinds/pinned/consumer.rs
+++ b/core/bench/src/args/kinds/pinned/consumer.rs
@@ -16,7 +16,7 @@
// under the License.
use crate::args::{
- common::IggyBenchArgs, defaults::DEFAULT_NUMBER_OF_PRODUCERS, props::BenchmarkKindProps,
+ common::IggyBenchArgs, defaults::DEFAULT_NUMBER_OF_CONSUMERS, props::BenchmarkKindProps,
transport::BenchmarkTransportCommand,
};
use clap::{CommandFactory, Parser, error::ErrorKind};
@@ -34,7 +34,7 @@
pub streams: Option<NonZeroU32>,
/// Number of consumers
- #[arg(long, short = 'c', default_value_t = DEFAULT_NUMBER_OF_PRODUCERS)]
+ #[arg(long, short = 'c', default_value_t = DEFAULT_NUMBER_OF_CONSUMERS)]
pub consumers: NonZeroU32,
}
@@ -74,7 +74,7 @@
if streams > consumers {
cmd.error(
ErrorKind::ArgumentConflict,
- format!("For pinned consumer, number of streams ({streams}) must be equal to the number of consumers ({consumers}).",
+ format!("For pinned consumer, number of streams ({streams}) must be less than or equal to the number of consumers ({consumers}).",
))
.exit();
}
diff --git a/core/bench/src/args/transport.rs b/core/bench/src/args/transport.rs
index eaec20e..8d952e9 100644
--- a/core/bench/src/args/transport.rs
+++ b/core/bench/src/args/transport.rs
@@ -30,22 +30,27 @@
Http(HttpArgs),
Tcp(TcpArgs),
Quic(QuicArgs),
- #[command(alias = "ws")]
+ #[command(name = "websocket", alias = "ws")]
WebSocket(WebSocketArgs),
}
+impl BenchmarkTransportCommand {
+ pub const fn as_str(&self) -> &'static str {
+ match self {
+ Self::Http(_) => "http",
+ Self::Tcp(_) => "tcp",
+ Self::Quic(_) => "quic",
+ Self::WebSocket(_) => "websocket",
+ }
+ }
+}
+
impl Serialize for BenchmarkTransportCommand {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
- let variant_str = match self {
- Self::Http(_) => "http",
- Self::Tcp(_) => "tcp",
- Self::Quic(_) => "quic",
- Self::WebSocket(_) => "websocket",
- };
- serializer.serialize_str(variant_str)
+ serializer.serialize_str(self.as_str())
}
}
diff --git a/core/bench/src/benchmarks/benchmark.rs b/core/bench/src/benchmarks/benchmark.rs
index 356b812..c5359de 100644
--- a/core/bench/src/benchmarks/benchmark.rs
+++ b/core/bench/src/benchmarks/benchmark.rs
@@ -123,17 +123,17 @@
.max_topic_size()
.map_or(MaxTopicSize::Unlimited, MaxTopicSize::Custom);
let message_expiry = self.args().message_expiry();
- let enforce_fsync = self.args().enforce_fsync();
+ let durability = self.args().durability();
let messages_required_to_save =
self.args().messages_required_to_save().map(NonZeroU32::get);
info!(
- "Creating the test topic '{}' for stream '{}' with max topic size: {:?}, message expiry: {}, enforce fsync: {}, messages required to save: {:?}",
+ "Creating the test topic '{}' for stream '{}' with max topic size: {:?}, message expiry: {}, durability: {}, messages required to save: {:?}",
topic_name,
stream_name,
max_topic_size,
message_expiry,
- enforce_fsync,
+ durability,
messages_required_to_save
);
@@ -147,7 +147,8 @@
.then_some(message_expiry),
max_topic_size: (max_topic_size != MaxTopicSize::ServerDefault)
.then_some(max_topic_size),
- enforce_fsync: enforce_fsync.then_some(true),
+ durability,
+ consumer_offset_durability: self.args().consumer_offset_durability(),
messages_required_to_save,
..TopicCreateOptions::default()
},
diff --git a/core/bench/src/utils/mod.rs b/core/bench/src/utils/mod.rs
index 973271f..671f6ec 100644
--- a/core/bench/src/utils/mod.rs
+++ b/core/bench/src/utils/mod.rs
@@ -32,7 +32,7 @@
DEFAULT_MESSAGES_PER_BATCH, DEFAULT_NUMBER_OF_CONSUMER_GROUPS, DEFAULT_NUMBER_OF_CONSUMERS,
DEFAULT_NUMBER_OF_PRODUCERS, DEFAULT_PINNED_NUMBER_OF_PARTITIONS,
DEFAULT_PINNED_NUMBER_OF_STREAMS, DEFAULT_QUIC_SERVER_ADDRESS, DEFAULT_TCP_SERVER_ADDRESS,
- DEFAULT_TOTAL_MESSAGES_SIZE, DEFAULT_WARMUP_TIME,
+ DEFAULT_TOTAL_MESSAGES_SIZE, DEFAULT_WARMUP_TIME, DEFAULT_WEBSOCKET_SERVER_ADDRESS,
},
};
@@ -198,6 +198,15 @@
}
fn add_basic_arguments(parts: &mut Vec<String>, args: &IggyBenchArgs) {
+ parts.push(format!("--durability {}", args.durability()));
+ parts.push(format!(
+ "--consumer-offset-durability {}",
+ args.consumer_offset_durability()
+ ));
+ if let Some(threshold) = args.messages_required_to_save() {
+ parts.push(format!("--messages-required-to-save {threshold}"));
+ }
+
let messages_per_batch = args.messages_per_batch();
if messages_per_batch != BenchmarkNumericParameter::Value(DEFAULT_MESSAGES_PER_BATCH.get()) {
parts.push(format!("--messages-per-batch {messages_per_batch}"));
@@ -212,7 +221,7 @@
if let Some(total_messages_size) = args.total_data()
&& total_messages_size != DEFAULT_TOTAL_MESSAGES_SIZE
{
- parts.push(format!("--total-messages-size {total_messages_size}"));
+ parts.push(format!("--total-data {total_messages_size}"));
}
let message_size = args.message_size();
@@ -291,7 +300,8 @@
let default_streams = match args.benchmark_kind.as_simple_kind() {
BenchmarkKind::BalancedProducerAndConsumerGroup
| BenchmarkKind::BalancedConsumerGroup
- | BenchmarkKind::BalancedProducer => DEFAULT_BALANCED_NUMBER_OF_STREAMS.get(),
+ | BenchmarkKind::BalancedProducer
+ | BenchmarkKind::EndToEndProducingConsumerGroup => DEFAULT_BALANCED_NUMBER_OF_STREAMS.get(),
_ => DEFAULT_PINNED_NUMBER_OF_STREAMS.get(),
};
if streams != default_streams {
@@ -302,10 +312,13 @@
let default_partitions = match args.benchmark_kind.as_simple_kind() {
BenchmarkKind::BalancedProducerAndConsumerGroup
| BenchmarkKind::BalancedConsumerGroup
- | BenchmarkKind::BalancedProducer => DEFAULT_BALANCED_NUMBER_OF_PARTITIONS.get(),
+ | BenchmarkKind::BalancedProducer
+ | BenchmarkKind::EndToEndProducingConsumerGroup => {
+ DEFAULT_BALANCED_NUMBER_OF_PARTITIONS.get()
+ }
_ => DEFAULT_PINNED_NUMBER_OF_PARTITIONS.get(),
};
- if partitions != default_partitions {
+ if partitions != 0 && partitions != default_partitions {
parts.push(format!("--partitions {partitions}"));
}
@@ -321,14 +334,15 @@
parts.push(format!("--max-topic-size \'{max_topic_size}\'"));
}
- let transport = args.transport().to_string().to_lowercase();
- parts.push(transport.clone());
+ let transport = args.transport_command().as_str();
+ parts.push(transport.to_owned());
let server_address = args.server_address();
- let default_address = match transport.as_str() {
+ let default_address = match transport {
"tcp" => DEFAULT_TCP_SERVER_ADDRESS,
"quic" => DEFAULT_QUIC_SERVER_ADDRESS,
"http" => DEFAULT_HTTP_SERVER_ADDRESS,
+ "websocket" => DEFAULT_WEBSOCKET_SERVER_ADDRESS,
_ => "",
};
@@ -345,3 +359,89 @@
parts.push(format!("--remark \'{remark}\'"));
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::recreate_bench_command;
+ use crate::args::common::IggyBenchArgs;
+ use clap::Parser;
+
+ #[test]
+ fn reproduced_commands_preserve_consumer_only_and_end_to_end_topologies() {
+ for arguments in [
+ vec!["iggy-bench", "pinned-consumer", "tcp"],
+ vec!["iggy-bench", "balanced-consumer-group", "tcp"],
+ vec!["iggy-bench", "end-to-end-producing-consumer-group", "tcp"],
+ vec![
+ "iggy-bench",
+ "end-to-end-producing-consumer-group",
+ "--streams",
+ "6",
+ "--consumer-groups",
+ "6",
+ "--partitions",
+ "1",
+ "tcp",
+ ],
+ ] {
+ let mut original = IggyBenchArgs::try_parse_from(arguments).unwrap();
+ original.validate();
+ let command = recreate_bench_command(&original);
+ let arguments = command
+ .split_ascii_whitespace()
+ .skip_while(|argument| *argument != "iggy-bench");
+ let mut reproduced = IggyBenchArgs::try_parse_from(arguments).unwrap();
+ reproduced.validate();
+ assert_eq!(reproduced.streams(), original.streams(), "{command}");
+ assert_eq!(
+ reproduced.number_of_partitions(),
+ original.number_of_partitions(),
+ "{command}"
+ );
+ assert_eq!(
+ reproduced.number_of_consumer_groups(),
+ original.number_of_consumer_groups(),
+ "{command}"
+ );
+ }
+ }
+
+ #[test]
+ fn reproduced_websocket_commands_preserve_independent_topic_policies() {
+ for messages in ["replicated", "persisted"] {
+ for offsets in ["replicated", "persisted"] {
+ let original = IggyBenchArgs::try_parse_from([
+ "iggy-bench",
+ "--durability",
+ messages,
+ "--consumer-offset-durability",
+ offsets,
+ "--messages-required-to-save",
+ "128",
+ "pinned-producer",
+ "ws",
+ ])
+ .unwrap();
+ let command = recreate_bench_command(&original);
+ let arguments = command
+ .split_ascii_whitespace()
+ .skip_while(|argument| *argument != "iggy-bench");
+ let reproduced = IggyBenchArgs::try_parse_from(arguments).unwrap();
+ assert!(
+ command
+ .split_ascii_whitespace()
+ .any(|argument| argument == "websocket")
+ );
+ assert_eq!(reproduced.durability(), original.durability());
+ assert_eq!(
+ reproduced.consumer_offset_durability(),
+ original.consumer_offset_durability()
+ );
+ assert_eq!(
+ reproduced.messages_required_to_save(),
+ original.messages_required_to_save()
+ );
+ }
+ }
+ }
+}
diff --git a/core/binary_protocol/Cargo.toml b/core/binary_protocol/Cargo.toml
index be97141..68ccaad 100644
--- a/core/binary_protocol/Cargo.toml
+++ b/core/binary_protocol/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy_binary_protocol"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
description = "Wire protocol types and codec for the Iggy binary protocol. Shared between server and SDK."
edition = "2024"
rust-version.workspace = true
diff --git a/core/binary_protocol/src/primitives/options.rs b/core/binary_protocol/src/primitives/options.rs
index 737b6f3..16e781b 100644
--- a/core/binary_protocol/src/primitives/options.rs
+++ b/core/binary_protocol/src/primitives/options.rs
@@ -397,8 +397,8 @@
#[test]
fn wire_options_constructors_validate() {
let buf = encode(&[
- (STRING, b"enforce_fsync", STRING, b"true"),
- (STRING, b"enforce_fsync", STRING, b"false"),
+ (STRING, b"preallocate_segments", STRING, b"true"),
+ (STRING, b"preallocate_segments", STRING, b"false"),
]);
assert!(WireOptions::from_slice(&buf).is_err());
assert!(WireOptions::from_bytes(buf.freeze()).is_err());
@@ -432,31 +432,32 @@
/// in their own unit tests, and a change to the TLV layout has to break all
/// of them together instead of leaving one SDK talking to itself.
///
- /// `enforce_fsync=true` (a `Bool`) and `segment_size=1 GiB` (a `Uint64`)
- /// cover both a one-byte and an eight-byte value. What the vector pins is
- /// the per-entry byte layout, not a key order: these two land sorted only
- /// because `iggy_common` holds options in a `BTreeMap`, and
- /// `unsorted_keys_are_accepted` covers the SDKs that emit insertion order.
+ /// The vector covers Bool, Uint64 and String values in insertion order.
+ /// It deliberately differs from Rust's sorted map order. Decoders accept
+ /// either ordering, and the bytes pin the per-entry layout across SDKs.
const GOLDEN_OPTIONS_BLOCK: &[u8] = &[
- 2, 13, 0, 0, 0, // key kind String, length 13
- b'e', b'n', b'f', b'o', b'r', b'c', b'e', b'_', b'f', b's', b'y', b'n', b'c', 3, 1, 0, 0,
- 0, 1, // value kind Bool, length 1, true
+ 2, 20, 0, 0, 0, // key kind String, length 20
+ b'p', b'r', b'e', b'a', b'l', b'l', b'o', b'c', b'a', b't', b'e', b'_', b's', b'e', b'g',
+ b'm', b'e', b'n', b't', b's', 3, 1, 0, 0, 0, 1, // value kind Bool, length 1, true
2, 12, 0, 0, 0, // key kind String, length 12
b's', b'e', b'g', b'm', b'e', b'n', b't', b'_', b's', b'i', b'z', b'e', 12, 8, 0, 0,
0, // value kind Uint64, length 8
0, 0, 0, 64, 0, 0, 0, 0, // 1 GiB little-endian
+ 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101,
+ 114, 115, 105, 115, 116, 101, 100,
];
#[test]
fn golden_options_block_is_byte_stable() {
let encoded = encode(&[
- (STRING, b"enforce_fsync", 3, &[1]),
+ (STRING, b"preallocate_segments", 3, &[1]),
(
STRING,
b"segment_size",
UINT64,
&1_073_741_824u64.to_le_bytes(),
),
+ (STRING, b"durability", STRING, b"persisted"),
]);
assert_eq!(
@@ -464,7 +465,7 @@
GOLDEN_OPTIONS_BLOCK,
"the options TLV layout changed; update every SDK's copy of this vector"
);
- assert_eq!(validate_options(GOLDEN_OPTIONS_BLOCK).unwrap(), 2);
+ assert_eq!(validate_options(GOLDEN_OPTIONS_BLOCK).unwrap(), 3);
}
#[test]
@@ -476,7 +477,7 @@
UINT64,
&1_073_741_824u64.to_le_bytes(),
),
- (STRING, b"enforce_fsync", 3, &[1]),
+ (STRING, b"preallocate_segments", 3, &[1]),
]);
assert_ne!(&unsorted[..], GOLDEN_OPTIONS_BLOCK);
diff --git a/core/cli/Cargo.toml b/core/cli/Cargo.toml
index f8a2b89..877bc79 100644
--- a/core/cli/Cargo.toml
+++ b/core/cli/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy-cli"
-version = "0.14.0-edge.7"
+version = "0.14.0-edge.8"
edition = "2024"
rust-version.workspace = true
authors = ["bartosz.ciesla@gmail.com"]
diff --git a/core/cli/src/args/topic.rs b/core/cli/src/args/topic.rs
index 875b370..b5447ef 100644
--- a/core/cli/src/args/topic.rs
+++ b/core/cli/src/args/topic.rs
@@ -122,6 +122,12 @@
/// "server_default" or skipping parameter makes CLI to use server default (from current server config) expiry time
#[arg(default_value = "server_default", value_parser = clap::value_parser!(IggyExpiry), verbatim_doc_comment)]
pub(crate) message_expiry: Vec<IggyExpiry>,
+ /// Message completion policy: replicated or persisted. Both policies store messages on disk.
+ #[arg(long, default_value_t = iggy_common::Durability::Replicated)]
+ pub(crate) durability: iggy_common::Durability,
+ /// Offset completion policy: replicated or persisted. Independent of message durability.
+ #[arg(long, default_value_t = iggy_common::Durability::Replicated)]
+ pub(crate) consumer_offset_durability: iggy_common::Durability,
/// Additional topic option as key=value, repeatable
///
/// Values are sent as strings and parsed server-side through each option's
@@ -216,3 +222,66 @@
#[arg(value_parser = clap::value_parser!(Identifier))]
pub(crate) topic_id: Identifier,
}
+
+#[cfg(test)]
+mod durability_tests {
+ use super::TopicCreateArgs;
+ use clap::Parser;
+ use iggy_common::Durability;
+
+ #[derive(Parser)]
+ struct Create {
+ #[command(flatten)]
+ args: TopicCreateArgs,
+ }
+
+ #[test]
+ fn topic_durability_defaults_are_independent() {
+ let parsed = Create::try_parse_from([
+ "iggy",
+ "stream",
+ "topic",
+ "1",
+ "none",
+ "--durability",
+ "persisted",
+ ])
+ .unwrap();
+ assert_eq!(parsed.args.durability, Durability::Persisted);
+ assert_eq!(
+ parsed.args.consumer_offset_durability,
+ Durability::Replicated
+ );
+ let parsed = Create::try_parse_from([
+ "iggy",
+ "stream",
+ "topic",
+ "1",
+ "none",
+ "--consumer-offset-durability",
+ "persisted",
+ ])
+ .unwrap();
+ assert_eq!(parsed.args.durability, Durability::Replicated);
+ assert_eq!(
+ parsed.args.consumer_offset_durability,
+ Durability::Persisted
+ );
+ }
+
+ #[test]
+ fn unknown_durability_is_rejected() {
+ assert!(
+ Create::try_parse_from([
+ "iggy",
+ "stream",
+ "topic",
+ "1",
+ "none",
+ "--durability",
+ "memory"
+ ])
+ .is_err()
+ );
+ }
+}
diff --git a/core/cli/src/commands/binary_topics/create_topic.rs b/core/cli/src/commands/binary_topics/create_topic.rs
index ec9209d..f10d98d 100644
--- a/core/cli/src/commands/binary_topics/create_topic.rs
+++ b/core/cli/src/commands/binary_topics/create_topic.rs
@@ -19,8 +19,8 @@
use anyhow::Context;
use async_trait::async_trait;
use core::fmt;
-use iggy_common::Client;
use iggy_common::create_topic::CreateTopic;
+use iggy_common::{Client, Durability};
use iggy_common::{CompressionAlgorithm, Identifier, IggyExpiry, MaxTopicSize, TopicCreateOptions};
use std::collections::BTreeMap;
use tracing::{Level, event};
@@ -30,6 +30,8 @@
message_expiry: IggyExpiry,
max_topic_size: MaxTopicSize,
raw_options: BTreeMap<String, String>,
+ durability: Durability,
+ consumer_offset_durability: Durability,
}
impl CreateTopicCmd {
@@ -42,6 +44,8 @@
message_expiry: IggyExpiry,
max_topic_size: MaxTopicSize,
raw_options: BTreeMap<String, String>,
+ durability: Durability,
+ consumer_offset_durability: Durability,
) -> Self {
Self {
create_topic: CreateTopic {
@@ -56,6 +60,8 @@
message_expiry,
max_topic_size,
raw_options,
+ durability,
+ consumer_offset_durability,
}
}
}
@@ -82,6 +88,8 @@
!= MaxTopicSize::ServerDefault)
.then_some(self.create_topic.max_topic_size),
raw: self.raw_options.clone(),
+ durability: self.durability,
+ consumer_offset_durability: self.consumer_offset_durability,
..TopicCreateOptions::default()
},
)
diff --git a/core/cli/src/main.rs b/core/cli/src/main.rs
index 93a7361..7849d39 100644
--- a/core/cli/src/main.rs
+++ b/core/cli/src/main.rs
@@ -134,6 +134,8 @@
args.message_expiry.clone().into(),
args.max_topic_size,
args.set.iter().cloned().collect(),
+ args.durability,
+ args.consumer_offset_durability,
)),
TopicAction::Delete(args) => Box::new(DeleteTopicCmd::new(
args.stream_id.clone(),
diff --git a/core/common/Cargo.toml b/core/common/Cargo.toml
index 2e059fc..97d1d63 100644
--- a/core/common/Cargo.toml
+++ b/core/common/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy_common"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
edition = "2024"
rust-version.workspace = true
diff --git a/core/common/src/traits/message_client.rs b/core/common/src/traits/message_client.rs
index b710176..bf850ae 100644
--- a/core/common/src/traits/message_client.rs
+++ b/core/common/src/traits/message_client.rs
@@ -100,9 +100,9 @@
/// A reported `base_offset` is where the batch's first message landed, with
/// two limits. Delivery is at-least-once, so an earlier retry may already
/// have committed the same batch at a lower offset and the value never
- /// implies uniqueness. A batch is confirmed once it is committed in memory,
- /// not once it is fsynced, so a crash-restart can stamp a later batch with
- /// an offset a client has already recorded.
+ /// implies uniqueness. Confirmation follows VSR quorum commit. Persisted
+ /// message durability also requires recoverable stable-storage copies on
+ /// the quorum.
async fn send_messages(
&self,
stream_id: &Identifier,
diff --git a/core/common/src/types/options/durability.rs b/core/common/src/types/options/durability.rs
new file mode 100644
index 0000000..5bfca0f
--- /dev/null
+++ b/core/common/src/types/options/durability.rs
@@ -0,0 +1,53 @@
+// 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 clap::ValueEnum;
+use serde::{Deserialize, Serialize};
+use strum::{AsRefStr, Display, EnumString, IntoStaticStr};
+
+/// Storage guarantee required at explicit operation completion. Both policies persist data.
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ Default,
+ PartialEq,
+ Eq,
+ Serialize,
+ Deserialize,
+ Display,
+ EnumString,
+ AsRefStr,
+ IntoStaticStr,
+ ValueEnum,
+)]
+#[serde(rename_all = "lowercase")]
+#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
+pub enum Durability {
+ /// Quorum commit without an additional stable-storage barrier.
+ #[default]
+ Replicated,
+ /// Quorum commit backed by recoverable stable-storage copies.
+ Persisted,
+}
+
+impl Durability {
+ #[must_use]
+ pub const fn is_persisted(self) -> bool {
+ matches!(self, Self::Persisted)
+ }
+}
diff --git a/core/common/src/types/options/mod.rs b/core/common/src/types/options/mod.rs
index 44a2eaf..38e8036 100644
--- a/core/common/src/types/options/mod.rs
+++ b/core/common/src/types/options/mod.rs
@@ -53,6 +53,10 @@
//! Java each assert independently, so a new encoder has a fixture to match
//! rather than a description to interpret.
+mod durability;
+
+pub use durability::Durability;
+
use std::collections::BTreeMap;
use std::str::FromStr;
@@ -206,9 +210,9 @@
/// Bounded: a 512-byte multiple within
/// [`super::MIN_TOPIC_SEGMENT_SIZE`]..=the server's segment ceiling.
pub const SEGMENT_SIZE: &str = "segment_size";
- /// Whether writes to this topic's partitions fsync: `Bool`, or the
- /// strings `true` / `false`.
- pub const ENFORCE_FSYNC: &str = "enforce_fsync";
+ /// Message completion policy: `String`, either `replicated` or `persisted`.
+ pub const DURABILITY: &str = "durability";
+ pub const CONSUMER_OFFSET_DURABILITY: &str = "consumer_offset_durability";
/// Flush the journal once it holds this many messages: `Uint32`.
/// Must be non-zero.
pub const MESSAGES_REQUIRED_TO_SAVE: &str = "messages_required_to_save";
@@ -226,7 +230,7 @@
/// Values an absent topic option resolves to at admission.
///
/// These are the knobs' single source of truth: they used to live in
-/// `config.toml` (`[system.topic]`, `[system.partition]`, `[system.segment]`),
+/// topic creation options,
/// which meant every one of them had two homes and an operator could not tell
/// which won. A topic carries whatever it was created with; anything the
/// client did not send resolves to the constant here and is persisted as a
@@ -235,17 +239,15 @@
/// Each value matches what the shipped `config.toml` carried, so removing the
/// keys changed no behavior for a topic created without options.
pub const DEFAULT_PARTITIONS_COUNT: u32 = 1;
-/// `MaxTopicSize::Unlimited` (was `[system.topic] max_size = "unlimited"`).
+/// `MaxTopicSize::Unlimited` (was `[topic] max_size = "unlimited"`).
pub const DEFAULT_MAX_TOPIC_SIZE: u64 = u64::MAX;
-/// `IggyExpiry::NeverExpire` (was `[system.topic] message_expiry = "none"`).
+/// `IggyExpiry::NeverExpire` (was `[topic] message_expiry = "none"`).
pub const DEFAULT_MESSAGE_EXPIRY: u64 = u64::MAX;
-/// 1 GiB (was `[system.segment] size = "1 GiB"`).
+/// 1 GiB.
pub const DEFAULT_SEGMENT_SIZE: u64 = 1024 * 1024 * 1024;
-/// Was `[system.partition] enforce_fsync = false`.
-pub const DEFAULT_ENFORCE_FSYNC: bool = false;
-/// Was `[system.partition] messages_required_to_save = 1024`.
+/// Was `[partition] messages_required_to_save = 1024`.
pub const DEFAULT_MESSAGES_REQUIRED_TO_SAVE: u32 = 1024;
-/// Opt-in, unlike the `[system.segment] preallocate = true` this replaced.
+/// Preallocation is opt-in.
///
/// That default was never actually in force: the reservation ran through
/// `compio::spawn_blocking`, which panics the shard because shard executors
@@ -256,7 +258,7 @@
/// sweep reserved 393 GB before this was flipped. A topic that wants the
/// latency benefit asks for it with `preallocate_segments`.
pub const DEFAULT_PREALLOCATE_SEGMENTS: bool = false;
-/// 1 MiB (was `[system.partition] size_of_messages_required_to_save`).
+/// 1 MiB (was `[partition] size_of_messages_required_to_save`).
pub const DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE: u64 = 1024 * 1024;
/// Every runtime knob at its default, for a partition built with no resolved
@@ -265,7 +267,8 @@
fn default() -> Self {
Self {
segment_size: IggyByteSize::from(DEFAULT_SEGMENT_SIZE),
- enforce_fsync: DEFAULT_ENFORCE_FSYNC,
+ durability: Durability::default(),
+ consumer_offset_durability: Durability::default(),
messages_required_to_save: DEFAULT_MESSAGES_REQUIRED_TO_SAVE,
size_of_messages_required_to_save: IggyByteSize::from(
DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
@@ -452,7 +455,8 @@
topic_option_keys::MESSAGE_EXPIRY,
topic_option_keys::MAX_TOPIC_SIZE,
topic_option_keys::SEGMENT_SIZE,
- topic_option_keys::ENFORCE_FSYNC,
+ topic_option_keys::DURABILITY,
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
topic_option_keys::MESSAGES_REQUIRED_TO_SAVE,
topic_option_keys::SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
topic_option_keys::PREALLOCATE_SEGMENTS,
@@ -466,7 +470,7 @@
/// caller meant to or not. As options they are patched: a key the client did
/// not send keeps its current value.
///
-/// The partition runtime knobs (`segment_size`, `enforce_fsync`, both flush
+/// The partition runtime knobs (`segment_size`, both durability policies, both flush
/// thresholds, `preallocate_segments`) stay out, and not only because nothing
/// re-pushes them to a live partition. They describe how a partition's storage
/// was laid down: changing `segment_size` mid-segment leaves one segment sized
@@ -590,38 +594,47 @@
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TopicRuntimeDefaults {
pub segment_size: IggyByteSize,
- pub enforce_fsync: bool,
+ pub durability: Durability,
+ pub consumer_offset_durability: Durability,
pub messages_required_to_save: u32,
pub size_of_messages_required_to_save: IggyByteSize,
pub preallocate_segments: bool,
}
/// A topic's resolved runtime knobs, as carried from the metadata plane to
-/// each of its partitions. `None` means "keep the shard-wide configured
-/// value": topics created without an options block (simulator, unit tests)
-/// have no resolved values to carry.
+/// each of its partitions. An unset segment size uses `DEFAULT_SEGMENT_SIZE`;
+/// other unset fields keep their shard defaults. Topics without an options
+/// block (simulator, unit tests) have no resolved values to carry.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TopicRuntimeOptions {
pub segment_size: Option<IggyByteSize>,
- pub enforce_fsync: Option<bool>,
+ pub durability: Durability,
+ pub consumer_offset_durability: Durability,
pub messages_required_to_save: Option<u32>,
pub size_of_messages_required_to_save: Option<IggyByteSize>,
pub preallocate_segments: Option<bool>,
}
impl TopicRuntimeOptions {
+ #[must_use]
+ pub fn effective_segment_size(self) -> IggyByteSize {
+ self.segment_size
+ .unwrap_or_else(|| IggyByteSize::from(DEFAULT_SEGMENT_SIZE))
+ }
+
/// Derive the runtime knobs from a topic's persisted options map.
///
/// Degrades per key, not per map. An entry this build cannot interpret
/// leaves its own knob unset and every other knob intact, so one key a
- /// newer node wrote cannot silently drop a topic's `enforce_fsync` or
+ /// newer node wrote cannot silently drop a topic's durability or
/// reset its segment size along with it.
#[must_use]
pub fn from_resource_options(options: &ResourceOptions) -> Self {
let parsed = TopicCreateOptions::from_resource_options(options);
Self {
segment_size: parsed.segment_size,
- enforce_fsync: parsed.enforce_fsync,
+ durability: parsed.durability,
+ consumer_offset_durability: parsed.consumer_offset_durability,
messages_required_to_save: parsed.messages_required_to_save,
size_of_messages_required_to_save: parsed.size_of_messages_required_to_save,
preallocate_segments: parsed.preallocate_segments,
@@ -691,9 +704,10 @@
/// Typed view of the known topic option keys, parsed from a wire block.
///
-/// `None` means the key was absent, which always means "resolve from server
-/// defaults at admission". Values that parse to their type's `ServerDefault`
-/// sentinel are normalized to `None` for the same reason.
+/// Optional fields left as `None` resolve from server defaults at admission.
+/// The two durability fields instead default independently to `Replicated`
+/// and are sent explicitly. Provenance describes the wire request, not whether
+/// application code assigned a field after constructing its default value.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TopicCreateOptions {
/// Partitions to allocate. NOT an option key: it fills the `CreateTopic`
@@ -706,14 +720,14 @@
pub compression_algorithm: Option<CompressionAlgorithm>,
pub message_expiry: Option<IggyExpiry>,
pub max_topic_size: Option<MaxTopicSize>,
- /// Per-topic segment size; `None` resolves against `[system.segment]
- /// size` at admission. `0` is normalized to `None`.
+ /// Per-topic segment size. `None` resolves to 1 GiB at admission.
+ /// `0` is normalized to `None`.
pub segment_size: Option<IggyByteSize>,
- /// Per-topic fsync enforcement; `None` resolves against
- /// `[system.partition] enforce_fsync`.
- pub enforce_fsync: Option<bool>,
- /// Per-topic message-count flush threshold; `None` resolves against
- /// `[system.partition] messages_required_to_save`. `0` is rejected.
+ /// Message completion policy, independent of consumer-offset durability.
+ pub durability: Durability,
+ pub consumer_offset_durability: Durability,
+ /// Per-topic message-count flush threshold. `None` resolves to 1024.
+ /// `0` is rejected.
pub messages_required_to_save: Option<u32>,
/// Per-topic byte flush threshold; `None` resolves against
/// [`DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE`].
@@ -730,6 +744,22 @@
}
impl TopicCreateOptions {
+ pub fn to_explicit_wire(
+ &self,
+ is_supplied: impl Fn(&str) -> bool,
+ ) -> Result<WireOptions, IggyError> {
+ let mut options = self.to_option_map()?;
+ for key in [
+ topic_option_keys::DURABILITY,
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ ] {
+ if !is_supplied(key) {
+ options.remove(&HeaderKey::from_str(key).expect("catalog key is valid"));
+ }
+ }
+ crate::wire_conversions::resource_options_to_wire(&options, OptionsProvenance::All)
+ }
+
/// Parse a wire options block against the topic catalog.
///
/// # Errors
@@ -800,8 +830,11 @@
let size = parse_byte_size(entry, key)?;
parsed.segment_size = (size != 0).then_some(IggyByteSize::from(size));
}
- topic_option_keys::ENFORCE_FSYNC => {
- parsed.enforce_fsync = Some(parse_bool(entry, key)?);
+ topic_option_keys::DURABILITY => {
+ parsed.durability = Self::durability_value(entry, key)?;
+ }
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY => {
+ parsed.consumer_offset_durability = Self::durability_value(entry, key)?;
}
topic_option_keys::MESSAGES_REQUIRED_TO_SAVE => {
let messages = parse_u32(entry, key)?;
@@ -843,7 +876,8 @@
message_expiry: self.message_expiry.or(defaults.message_expiry),
max_topic_size: self.max_topic_size.or(defaults.max_topic_size),
segment_size: self.segment_size.or(defaults.segment_size),
- enforce_fsync: self.enforce_fsync.or(defaults.enforce_fsync),
+ durability: self.durability,
+ consumer_offset_durability: self.consumer_offset_durability,
messages_required_to_save: self
.messages_required_to_save
.or(defaults.messages_required_to_save),
@@ -909,11 +943,25 @@
OptionValue::explicit(HeaderValue::from(segment_size.as_bytes_u64())),
);
}
- if let Some(enforce_fsync) = self.enforce_fsync {
+ for (key, policy) in [
+ (topic_option_keys::DURABILITY, self.durability),
+ (
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ self.consumer_offset_durability,
+ ),
+ ] {
+ let policy = self.raw.get(key).map_or(Ok(policy), |raw| {
+ let raw = raw
+ .parse::<Durability>()
+ .map_err(|_| IggyError::InvalidOptionValue(key.to_owned()))?;
+ if policy.is_persisted() && raw != policy {
+ return Err(IggyError::InvalidOptionValue(key.to_owned()));
+ }
+ Ok(raw)
+ })?;
options.insert(
- HeaderKey::from_str(topic_option_keys::ENFORCE_FSYNC)
- .expect("catalog key is a valid header key"),
- OptionValue::explicit(HeaderValue::from(enforce_fsync)),
+ HeaderKey::from_str(key).expect("catalog key is valid"),
+ OptionValue::explicit(HeaderValue::from_str(policy.as_ref())?),
);
}
if let Some(messages_required_to_save) = self.messages_required_to_save {
@@ -949,8 +997,8 @@
/// The rest ride as strings and are parsed server-side by the same
/// `FromStr` rules a config file value goes through, so a typed field
/// survives the round trip rather than being silently dropped.
- #[must_use]
- pub fn to_string_options(&self) -> BTreeMap<String, String> {
+ pub fn to_string_options(&self) -> Result<BTreeMap<String, String>, IggyError> {
+ let resolved = self.to_option_map()?;
// Typed fields are inserted over the raw entries, matching the
// collision rule `to_wire` applies.
let mut options = self.raw.clone();
@@ -960,12 +1008,20 @@
segment_size.as_bytes_u64().to_string(),
);
}
- if let Some(enforce_fsync) = self.enforce_fsync {
- options.insert(
- topic_option_keys::ENFORCE_FSYNC.to_owned(),
- enforce_fsync.to_string(),
- );
- }
+ options.insert(
+ topic_option_keys::DURABILITY.to_owned(),
+ resolved[&HeaderKey::from_str(topic_option_keys::DURABILITY)
+ .expect("catalog key is valid")]
+ .value
+ .to_string_value(),
+ );
+ options.insert(
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY.to_owned(),
+ resolved[&HeaderKey::from_str(topic_option_keys::CONSUMER_OFFSET_DURABILITY)
+ .expect("catalog key is valid")]
+ .value
+ .to_string_value(),
+ );
if let Some(messages_required_to_save) = self.messages_required_to_save {
options.insert(
topic_option_keys::MESSAGES_REQUIRED_TO_SAVE.to_owned(),
@@ -984,7 +1040,7 @@
preallocate_segments.to_string(),
);
}
- options
+ Ok(options)
}
/// Encode the resolved values for every key the client did NOT send into
@@ -1000,6 +1056,7 @@
message_expiry: IggyExpiry,
max_topic_size: MaxTopicSize,
runtime_defaults: TopicRuntimeDefaults,
+ supplied: &WireOptions,
) -> Result<WireOptions, IggyError> {
let mut derived = ResourceOptions::new();
if self.compression_algorithm.is_none() {
@@ -1035,12 +1092,22 @@
)),
);
}
- if self.enforce_fsync.is_none() {
- derived.insert(
- HeaderKey::from_str(topic_option_keys::ENFORCE_FSYNC)
- .expect("catalog key is a valid header key"),
- OptionValue::derived(HeaderValue::from(runtime_defaults.enforce_fsync)),
- );
+ for (key, policy) in [
+ (topic_option_keys::DURABILITY, runtime_defaults.durability),
+ (
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ runtime_defaults.consumer_offset_durability,
+ ),
+ ] {
+ if !supplied
+ .into_iter()
+ .any(|entry| entry.key == key.as_bytes())
+ {
+ derived.insert(
+ HeaderKey::from_str(key).expect("catalog key is valid"),
+ OptionValue::derived(HeaderValue::from_str(policy.as_ref())?),
+ );
+ }
}
if self.messages_required_to_save.is_none() {
derived.insert(
@@ -1100,6 +1167,18 @@
}
parsed
}
+ fn durability_value(
+ entry: &WireUserHeaderEntry<'_>,
+ key: &str,
+ ) -> Result<Durability, IggyError> {
+ if entry.value_kind.0 != HeaderKind::String.as_code() {
+ return Err(IggyError::InvalidOptionValue(key.to_owned()));
+ }
+ std::str::from_utf8(entry.value)
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .ok_or_else(|| IggyError::InvalidOptionValue(key.to_owned()))
+ }
}
fn parse_u32(entry: &WireUserHeaderEntry<'_>, key: &str) -> Result<u32, IggyError> {
@@ -1207,11 +1286,12 @@
message_expiry: Some(IggyExpiry::from(5_000_000u64)),
max_topic_size: Some(MaxTopicSize::from(2_000_000_000u64)),
segment_size: Some(IggyByteSize::from(134_217_728u64)),
- enforce_fsync: Some(true),
+ durability: Durability::Persisted,
messages_required_to_save: Some(500),
size_of_messages_required_to_save: Some(IggyByteSize::from(2_097_152u64)),
preallocate_segments: Some(false),
partitions_count: None,
+ consumer_offset_durability: Durability::Replicated,
raw: BTreeMap::new(),
};
let parsed = TopicCreateOptions::parse(&options.to_wire().unwrap()).unwrap();
@@ -1227,7 +1307,7 @@
partitions_count: Some(7),
..TopicCreateOptions::default()
};
- assert!(options.to_wire().unwrap().is_empty());
+ assert_eq!(options.to_wire().unwrap().into_iter().count(), 2);
// ...and the key is rejected if a client hand-rolls it into the block.
let raw = TopicCreateOptions {
raw: BTreeMap::from([("partitions_count".to_string(), "7".to_string())]),
@@ -1325,7 +1405,7 @@
max_topic_size: Some(MaxTopicSize::from(0u64)),
segment_size: Some(IggyByteSize::from(0u64)),
size_of_messages_required_to_save: Some(IggyByteSize::from(0u64)),
- enforce_fsync: Some(true),
+ durability: Durability::Persisted,
..TopicCreateOptions::default()
};
let parsed = TopicCreateOptions::parse(&sent.to_wire().unwrap()).unwrap();
@@ -1345,16 +1425,14 @@
);
}
// A non-sentinel key alongside them still rides through untouched.
- assert!(
- stored.contains_key(&HeaderKey::from_str(topic_option_keys::ENFORCE_FSYNC).unwrap())
- );
+ assert!(stored.contains_key(&HeaderKey::from_str(topic_option_keys::DURABILITY).unwrap()));
}
#[test]
fn runtime_options_derive_from_the_persisted_map() {
let options = TopicCreateOptions {
segment_size: Some(IggyByteSize::from(2_097_152u64)),
- enforce_fsync: Some(true),
+ durability: Durability::Persisted,
messages_required_to_save: Some(9),
..TopicCreateOptions::default()
};
@@ -1365,20 +1443,103 @@
.unwrap();
let runtime = TopicRuntimeOptions::from_resource_options(&persisted);
assert_eq!(runtime.segment_size, Some(IggyByteSize::from(2_097_152u64)));
- assert_eq!(runtime.enforce_fsync, Some(true));
+ assert_eq!(runtime.durability, Durability::Persisted);
assert_eq!(runtime.messages_required_to_save, Some(9));
assert_eq!(runtime.size_of_messages_required_to_save, None);
}
#[test]
- fn typed_field_wins_over_raw_entry_for_the_same_key() {
+ fn raw_durability_can_strengthen_the_replicated_default() {
let options = TopicCreateOptions {
- enforce_fsync: Some(true),
- raw: BTreeMap::from([("enforce_fsync".to_string(), "false".to_string())]),
+ raw: BTreeMap::from([("durability".to_owned(), "persisted".to_owned())]),
..TopicCreateOptions::default()
};
let parsed = TopicCreateOptions::parse(&options.to_wire().unwrap()).unwrap();
- assert_eq!(parsed.enforce_fsync, Some(true));
+ assert_eq!(parsed.durability, Durability::Persisted);
+ assert_eq!(parsed.consumer_offset_durability, Durability::Replicated);
+ assert_eq!(
+ options.to_string_options().unwrap()["durability"],
+ "persisted"
+ );
+ }
+
+ #[test]
+ fn conflicting_durability_is_rejected_by_both_encoders() {
+ let options = TopicCreateOptions {
+ durability: Durability::Persisted,
+ raw: BTreeMap::from([("durability".to_string(), "replicated".to_string())]),
+ ..TopicCreateOptions::default()
+ };
+ assert!(options.to_wire().is_err());
+ assert!(options.to_string_options().is_err());
+ }
+
+ #[test]
+ fn omitted_durability_is_independent_and_remains_derived() {
+ for (key, other) in [
+ (
+ topic_option_keys::DURABILITY,
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ ),
+ (
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ topic_option_keys::DURABILITY,
+ ),
+ ] {
+ let supplied = crate::wire_conversions::resource_options_to_wire(
+ &ResourceOptions::from([(
+ HeaderKey::from_str(key).unwrap(),
+ OptionValue::explicit(HeaderValue::from_str("persisted").unwrap()),
+ )]),
+ OptionsProvenance::All,
+ )
+ .unwrap();
+ let parsed = TopicCreateOptions::parse(&supplied).unwrap();
+ let explicit = parsed
+ .to_explicit_wire(|key| {
+ supplied
+ .into_iter()
+ .any(|entry| entry.key == key.as_bytes())
+ })
+ .unwrap();
+ assert_eq!(explicit.into_iter().count(), 1);
+ let derived = parsed
+ .derived_block(
+ CompressionAlgorithm::default(),
+ IggyExpiry::default(),
+ MaxTopicSize::default(),
+ TopicRuntimeDefaults::default(),
+ &supplied,
+ )
+ .unwrap();
+ let entry = derived
+ .into_iter()
+ .find(|entry| entry.key == other.as_bytes())
+ .unwrap();
+ assert_eq!(entry.value, b"replicated");
+ }
+ }
+
+ #[test]
+ fn both_durability_defaults_are_emitted_and_legacy_key_is_refused() {
+ let options = TopicCreateOptions::default().to_option_map().unwrap();
+ for key in [
+ topic_option_keys::DURABILITY,
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ ] {
+ let value = &options[&HeaderKey::from_str(key).unwrap()];
+ assert_eq!(value.value.kind(), HeaderKind::String);
+ assert_eq!(value.value.as_bytes(), b"replicated");
+ assert!(value.explicit);
+ }
+ let legacy = TopicCreateOptions {
+ raw: BTreeMap::from([("enforce_fsync".to_string(), "true".to_string())]),
+ ..TopicCreateOptions::default()
+ };
+ assert!(matches!(
+ TopicCreateOptions::parse(&legacy.to_wire().unwrap()),
+ Err(IggyError::UnsupportedOptionKey(_))
+ ));
}
#[test]
@@ -1401,7 +1562,7 @@
let options = TopicCreateOptions {
raw: BTreeMap::from([
("segment_size".to_string(), "128MiB".to_string()),
- ("enforce_fsync".to_string(), "true".to_string()),
+ ("durability".to_string(), "replicated".to_string()),
(
"size_of_messages_required_to_save".to_string(),
"4KiB".to_string(),
@@ -1414,7 +1575,7 @@
parsed.segment_size,
Some(IggyByteSize::from(134_217_728u64))
);
- assert_eq!(parsed.enforce_fsync, Some(true));
+ assert_eq!(parsed.durability, Durability::Replicated);
assert_eq!(
parsed.size_of_messages_required_to_save,
Some(IggyByteSize::from(4096u64))
diff --git a/core/configs/src/common/defaults.rs b/core/configs/src/common/defaults.rs
index 1dddfcc..7381219 100644
--- a/core/configs/src/common/defaults.rs
+++ b/core/configs/src/common/defaults.rs
@@ -21,11 +21,7 @@
PersonalAccessTokenCleanerConfig, PersonalAccessTokenConfig, TelemetryConfig,
TelemetryLogsConfig, TelemetryTracesConfig,
};
-use super::system::{
- EncryptionConfig, LoggingConfig, PartitionConfig, RecoveryConfig, RuntimeConfig, SegmentConfig,
- StreamConfig, SystemConfig, TopicConfig,
-};
-use configs::ConfigEnvMappings;
+use super::system::{EncryptionConfig, LoggingConfig, RuntimeConfig};
static_toml::static_toml! {
// static_toml resolves relative to CARGO_MANIFEST_DIR (core/configs/).
@@ -172,24 +168,6 @@
}
}
-impl<S: ConfigEnvMappings + Default> Default for SystemConfig<S> {
- fn default() -> Self {
- Self {
- path: SERVER_CONFIG.system.path.parse().unwrap(),
- runtime: RuntimeConfig::default(),
- logging: LoggingConfig::default(),
- stream: StreamConfig::default(),
- encryption: EncryptionConfig::default(),
- topic: TopicConfig::default(),
- partition: PartitionConfig::default(),
- segment: SegmentConfig::default(),
- recovery: RecoveryConfig::default(),
- memory_pool: MemoryPoolConfig::default(),
- sharding: S::default(),
- }
- }
-}
-
impl Default for HeartbeatConfig {
fn default() -> HeartbeatConfig {
HeartbeatConfig {
@@ -214,7 +192,7 @@
impl Default for RuntimeConfig {
fn default() -> RuntimeConfig {
RuntimeConfig {
- path: SERVER_CONFIG.system.runtime.path.parse().unwrap(),
+ path: SERVER_CONFIG.runtime.path.parse().unwrap(),
}
}
}
@@ -222,18 +200,17 @@
impl Default for LoggingConfig {
fn default() -> LoggingConfig {
LoggingConfig {
- path: SERVER_CONFIG.system.logging.path.parse().unwrap(),
- level: SERVER_CONFIG.system.logging.level.parse().unwrap(),
- file_enabled: SERVER_CONFIG.system.logging.file_enabled,
- max_file_size: SERVER_CONFIG.system.logging.max_file_size.parse().unwrap(),
- max_total_size: SERVER_CONFIG.system.logging.max_total_size.parse().unwrap(),
+ path: SERVER_CONFIG.logging.path.parse().unwrap(),
+ level: SERVER_CONFIG.logging.level.parse().unwrap(),
+ file_enabled: SERVER_CONFIG.logging.file_enabled,
+ max_file_size: SERVER_CONFIG.logging.max_file_size.parse().unwrap(),
+ max_total_size: SERVER_CONFIG.logging.max_total_size.parse().unwrap(),
rotation_check_interval: SERVER_CONFIG
- .system
.logging
.rotation_check_interval
.parse()
.unwrap(),
- retention: SERVER_CONFIG.system.logging.retention.parse().unwrap(),
+ retention: SERVER_CONFIG.logging.retention.parse().unwrap(),
}
}
}
@@ -241,49 +218,8 @@
impl Default for EncryptionConfig {
fn default() -> EncryptionConfig {
EncryptionConfig {
- enabled: SERVER_CONFIG.system.encryption.enabled,
- key: SERVER_CONFIG.system.encryption.key.parse().unwrap(),
- }
- }
-}
-
-impl Default for StreamConfig {
- fn default() -> StreamConfig {
- StreamConfig {
- path: SERVER_CONFIG.system.stream.path.parse().unwrap(),
- }
- }
-}
-
-impl Default for TopicConfig {
- fn default() -> TopicConfig {
- TopicConfig {
- path: SERVER_CONFIG.system.topic.path.parse().unwrap(),
- }
- }
-}
-
-impl Default for PartitionConfig {
- fn default() -> PartitionConfig {
- PartitionConfig {
- path: SERVER_CONFIG.system.partition.path.parse().unwrap(),
- validate_checksum: SERVER_CONFIG.system.partition.validate_checksum,
- }
- }
-}
-
-impl Default for SegmentConfig {
- fn default() -> SegmentConfig {
- SegmentConfig {
- archive_expired: SERVER_CONFIG.system.segment.archive_expired,
- }
- }
-}
-
-impl Default for RecoveryConfig {
- fn default() -> RecoveryConfig {
- RecoveryConfig {
- recreate_missing_state: SERVER_CONFIG.system.recovery.recreate_missing_state,
+ enabled: SERVER_CONFIG.encryption.enabled,
+ key: SERVER_CONFIG.encryption.key.parse().unwrap(),
}
}
}
@@ -291,9 +227,9 @@
impl Default for MemoryPoolConfig {
fn default() -> MemoryPoolConfig {
Self {
- enabled: SERVER_CONFIG.system.memory_pool.enabled,
- size: SERVER_CONFIG.system.memory_pool.size.parse().unwrap(),
- bucket_capacity: SERVER_CONFIG.system.memory_pool.bucket_capacity as u32,
+ enabled: SERVER_CONFIG.memory_pool.enabled,
+ size: SERVER_CONFIG.memory_pool.size.parse().unwrap(),
+ bucket_capacity: SERVER_CONFIG.memory_pool.bucket_capacity as u32,
}
}
}
diff --git a/core/configs/src/common/displays.rs b/core/configs/src/common/displays.rs
index 32af517..454bba8 100644
--- a/core/configs/src/common/displays.rs
+++ b/core/configs/src/common/displays.rs
@@ -21,12 +21,8 @@
};
use super::{
http::{HttpConfig, HttpCorsConfig, HttpJwtConfig, HttpMetricsConfig, HttpTlsConfig},
- system::{
- EncryptionConfig, LoggingConfig, PartitionConfig, SegmentConfig, StreamConfig,
- SystemConfig, TopicConfig,
- },
+ system::{EncryptionConfig, LoggingConfig},
};
-use configs::ConfigEnvMappings;
use std::fmt::{Display, Formatter};
impl Display for HttpConfig {
@@ -130,34 +126,6 @@
}
}
-impl Display for StreamConfig {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{{ path: {} }}", self.path)
- }
-}
-
-impl Display for TopicConfig {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{{ path: {} }}", self.path)
- }
-}
-
-impl Display for PartitionConfig {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "{{ path: {}, validate_checksum: {} }}",
- self.path, self.validate_checksum
- )
- }
-}
-
-impl Display for SegmentConfig {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{{ archive_expired: {} }}", self.archive_expired,)
- }
-}
-
impl Display for LoggingConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
@@ -203,19 +171,3 @@
)
}
}
-
-impl<S: ConfigEnvMappings> Display for SystemConfig<S> {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "{{ path: {}, logging: {}, stream: {}, topic: {}, partition: {}, segment: {}, encryption: {} }}",
- self.path,
- self.logging,
- self.stream,
- self.topic,
- self.partition,
- self.segment,
- self.encryption,
- )
- }
-}
diff --git a/core/configs/src/common/mod.rs b/core/configs/src/common/mod.rs
index 4b2257a..8d7a3eb 100644
--- a/core/configs/src/common/mod.rs
+++ b/core/configs/src/common/mod.rs
@@ -16,7 +16,7 @@
// under the License.
//! Config vocabulary shared across the crate: the generic
-//! [`system::SystemConfig`], the HTTP section, and the top-level sections
+//! [`crate::server::ServerConfig`], the HTTP section, and the top-level sections
//! that [`crate::server_config::server::ServerConfig`] composes.
pub mod defaults;
diff --git a/core/configs/src/common/server.rs b/core/configs/src/common/server.rs
index 887cfe8..f3cec3b 100644
--- a/core/configs/src/common/server.rs
+++ b/core/configs/src/common/server.rs
@@ -26,7 +26,7 @@
pub use server_common::log::TelemetryTransport;
/// Configuration for the memory pool.
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
pub struct MemoryPoolConfig {
pub enabled: bool,
#[config_env(leaf)]
diff --git a/core/configs/src/common/system.rs b/core/configs/src/common/system.rs
index ae0e7ee..443708a 100644
--- a/core/configs/src/common/system.rs
+++ b/core/configs/src/common/system.rs
@@ -15,44 +15,24 @@
// specific language governing permissions and limitations
// under the License.
-use super::server::MemoryPoolConfig;
-use configs::{ConfigEnv, ConfigEnvMappings};
+use configs::ConfigEnv;
use iggy_common::IggyByteSize;
use iggy_common::IggyDuration;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
-use server_common::bootstrap::SystemPaths;
use server_common::log::LoggingSettings;
pub const INDEX_EXTENSION: &str = "index";
pub const LOG_EXTENSION: &str = "log";
-// Generic over the sharding config so every server flavour binds its own
-// `ShardingConfig` (different knob sets, different default source) while
-// sharing this whole struct and its path helpers.
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct SystemConfig<S: ConfigEnvMappings> {
- pub path: String,
- pub runtime: RuntimeConfig,
- pub logging: LoggingConfig,
- pub stream: StreamConfig,
- pub topic: TopicConfig,
- pub partition: PartitionConfig,
- pub segment: SegmentConfig,
- pub encryption: EncryptionConfig,
- pub recovery: RecoveryConfig,
- pub memory_pool: MemoryPoolConfig,
- pub sharding: S,
-}
-
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
pub struct RuntimeConfig {
pub path: String,
}
#[serde_as]
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
pub struct LoggingConfig {
pub path: String,
pub level: String,
@@ -83,7 +63,7 @@
}
}
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
pub struct EncryptionConfig {
pub enabled: bool,
// skip_serializing keeps the key out of the runtime current_config.toml (and
@@ -94,197 +74,6 @@
pub key: String,
}
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct StreamConfig {
- pub path: String,
-}
-
-/// Only the on-disk layout: a topic's size cap and message expiry are its own
-/// creation options now (`max_topic_size`, `message_expiry`), defaulting to
-/// `iggy_common::DEFAULT_MAX_TOPIC_SIZE` / `DEFAULT_MESSAGE_EXPIRY`.
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct TopicConfig {
- pub path: String,
-}
-
-/// `enforce_fsync`, `messages_required_to_save` and
-/// `size_of_messages_required_to_save` are per-topic creation options now,
-/// defaulting to the `iggy_common::DEFAULT_*` constants.
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct PartitionConfig {
- pub path: String,
- pub validate_checksum: bool,
-}
-
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct RecoveryConfig {
- pub recreate_missing_state: bool,
-}
-
-/// `size` and `preallocate` are per-topic creation options now
-/// (`segment_size`, `preallocate_segments`), defaulting to
-/// `iggy_common::DEFAULT_SEGMENT_SIZE` / `DEFAULT_PREALLOCATE_SEGMENTS`.
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
-pub struct SegmentConfig {
- pub archive_expired: bool,
-}
-
-impl<S: ConfigEnvMappings> SystemConfig<S> {
- pub fn get_system_path(&self) -> String {
- self.path.to_string()
- }
-
- pub fn get_state_path(&self) -> String {
- format!("{}/state", self.get_system_path())
- }
-
- pub fn get_state_messages_file_path(&self) -> String {
- format!("{}/log", self.get_state_path())
- }
-
- pub fn get_state_info_path(&self) -> String {
- format!("{}/info", self.get_state_path())
- }
- pub fn get_state_tokens_path(&self) -> String {
- format!("{}/tokens", self.get_state_path())
- }
-
- pub fn get_runtime_path(&self) -> String {
- format!("{}/{}", self.get_system_path(), self.runtime.path)
- }
-
- pub fn get_streams_path(&self) -> String {
- format!("{}/{}", self.get_system_path(), self.stream.path)
- }
-
- pub fn get_stream_path(&self, stream_id: usize) -> String {
- format!("{}/{}", self.get_streams_path(), stream_id)
- }
-
- pub fn get_topics_path(&self, stream_id: usize) -> String {
- format!("{}/{}", self.get_stream_path(stream_id), self.topic.path)
- }
-
- pub fn get_topic_path(&self, stream_id: usize, topic_id: usize) -> String {
- format!("{}/{}", self.get_topics_path(stream_id), topic_id)
- }
-
- pub fn get_partitions_path(&self, stream_id: usize, topic_id: usize) -> String {
- format!(
- "{}/{}",
- self.get_topic_path(stream_id, topic_id),
- self.partition.path
- )
- }
-
- pub fn get_partition_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- ) -> String {
- format!(
- "{}/{}",
- self.get_partitions_path(stream_id, topic_id),
- partition_id
- )
- }
-
- pub fn get_offsets_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- ) -> String {
- format!(
- "{}/offsets",
- self.get_partition_path(stream_id, topic_id, partition_id)
- )
- }
-
- pub fn get_consumer_offsets_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- ) -> String {
- format!(
- "{}/consumers",
- self.get_offsets_path(stream_id, topic_id, partition_id)
- )
- }
-
- pub fn get_consumer_group_offsets_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- ) -> String {
- format!(
- "{}/groups",
- self.get_offsets_path(stream_id, topic_id, partition_id)
- )
- }
-
- pub fn get_segment_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- start_offset: u64,
- ) -> String {
- format!(
- "{}/{:0>20}",
- self.get_partition_path(stream_id, topic_id, partition_id),
- start_offset
- )
- }
-
- pub fn get_messages_file_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- start_offset: u64,
- ) -> String {
- let path = self.get_segment_path(stream_id, topic_id, partition_id, start_offset);
- format!("{path}.{LOG_EXTENSION}")
- }
-
- pub fn get_index_path(
- &self,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
- start_offset: u64,
- ) -> String {
- let path = self.get_segment_path(stream_id, topic_id, partition_id, start_offset);
- format!("{path}.{INDEX_EXTENSION}")
- }
-}
-
-impl<S: ConfigEnvMappings> SystemPaths for SystemConfig<S> {
- fn get_system_path(&self) -> String {
- SystemConfig::get_system_path(self)
- }
-
- fn get_state_path(&self) -> String {
- SystemConfig::get_state_path(self)
- }
-
- fn get_state_messages_file_path(&self) -> String {
- SystemConfig::get_state_messages_file_path(self)
- }
-
- fn get_streams_path(&self) -> String {
- SystemConfig::get_streams_path(self)
- }
-
- fn get_runtime_path(&self) -> String {
- SystemConfig::get_runtime_path(self)
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/core/configs/src/common/validators.rs b/core/configs/src/common/validators.rs
index de3da58..26f7eb5 100644
--- a/core/configs/src/common/validators.rs
+++ b/core/configs/src/common/validators.rs
@@ -18,8 +18,7 @@
use super::COMPONENT;
use super::server::{DataMaintenanceConfig, MessagesMaintenanceConfig, TelemetryConfig};
use super::server::{MemoryPoolConfig, PersonalAccessTokenConfig};
-use super::system::SegmentConfig;
-use super::system::{LoggingConfig, PartitionConfig};
+use super::system::LoggingConfig;
use crate::ConfigurationError;
use cpu_allocation::{CpuAllocation, allowed_cpus};
use err_trail::ErrContext;
@@ -54,23 +53,6 @@
}
}
-impl Validatable<ConfigurationError> for PartitionConfig {
- fn validate(&self) -> Result<(), ConfigurationError> {
- // The flush thresholds this used to check are per-topic creation
- // options now; their bounds are enforced at admission.
- Ok(())
- }
-}
-
-impl Validatable<ConfigurationError> for SegmentConfig {
- fn validate(&self) -> Result<(), ConfigurationError> {
- // Segment size is a per-topic creation option now; its ceiling, floor
- // and 512 B-multiple rule are enforced by
- // `iggy_common::validate_topic_segment_size` at admission.
- Ok(())
- }
-}
-
impl Validatable<ConfigurationError> for DataMaintenanceConfig {
fn validate(&self) -> Result<(), ConfigurationError> {
self.messages.validate().error(|e: &ConfigurationError| {
@@ -112,13 +94,13 @@
impl Validatable<ConfigurationError> for LoggingConfig {
fn validate(&self) -> Result<(), ConfigurationError> {
if self.level.is_empty() {
- eprintln!("system.logging.level is supposed be configured");
+ eprintln!("logging.level is supposed be configured");
return Err(ConfigurationError::InvalidConfigurationValue);
}
if self.retention.as_secs() < 1 {
eprintln!(
- "Configured system.logging.retention {} is less than minimum 1 second",
+ "Configured logging.retention {} is less than minimum 1 second",
self.retention
);
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -126,7 +108,7 @@
if self.rotation_check_interval.as_secs() < 1 {
eprintln!(
- "Configured system.logging.rotation_check_interval {} is less than minimum 1 second",
+ "Configured logging.rotation_check_interval {} is less than minimum 1 second",
self.rotation_check_interval
);
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -137,7 +119,7 @@
&& self.max_file_size.as_bytes_u64() > self.max_total_size.as_bytes_u64()
{
eprintln!(
- "Configured system.logging.max_total_size {} is less than system.logging.max_file_size {}",
+ "Configured logging.max_total_size {} is less than logging.max_file_size {}",
self.max_total_size, self.max_file_size
);
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -150,9 +132,7 @@
impl Validatable<ConfigurationError> for MemoryPoolConfig {
fn validate(&self) -> Result<(), ConfigurationError> {
if self.enabled && self.size == 0 {
- eprintln!(
- "Configured system.memory_pool.enabled is true and system.memory_pool.size is 0"
- );
+ eprintln!("Configured memory_pool.enabled is true and memory_pool.size is 0");
return Err(ConfigurationError::InvalidConfigurationValue);
}
@@ -162,7 +142,7 @@
if self.enabled && self.size < MIN_POOL_SIZE {
eprintln!(
- "Configured system.memory_pool.size {} B ({} MiB) is less than minimum {} B, ({} MiB)",
+ "Configured memory_pool.size {} B ({} MiB) is less than minimum {} B, ({} MiB)",
self.size.as_bytes_u64(),
self.size.as_bytes_u64() / (1024 * 1024),
MIN_POOL_SIZE,
@@ -173,7 +153,7 @@
if self.enabled && !self.size.as_bytes_u64().is_multiple_of(DEFAULT_PAGE_SIZE) {
eprintln!(
- "Configured system.memory_pool.size {} B is not a multiple of default page size {} B",
+ "Configured memory_pool.size {} B is not a multiple of default page size {} B",
self.size.as_bytes_u64(),
DEFAULT_PAGE_SIZE
);
@@ -182,7 +162,7 @@
if self.enabled && self.bucket_capacity < MIN_BUCKET_CAPACITY {
eprintln!(
- "Configured system.memory_pool.buffers {} is less than minimum {}",
+ "Configured memory_pool.buffers {} is less than minimum {}",
self.bucket_capacity, MIN_BUCKET_CAPACITY
);
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -190,7 +170,7 @@
if self.enabled && !self.bucket_capacity.is_power_of_two() {
eprintln!(
- "Configured system.memory_pool.buffers {} is not a power of 2",
+ "Configured memory_pool.buffers {} is not a power of 2",
self.bucket_capacity
);
return Err(ConfigurationError::InvalidConfigurationValue);
diff --git a/core/configs/src/configs_impl/file_provider.rs b/core/configs/src/configs_impl/file_provider.rs
index b6b8d67..bc692d8 100644
--- a/core/configs/src/configs_impl/file_provider.rs
+++ b/core/configs/src/configs_impl/file_provider.rs
@@ -28,32 +28,36 @@
const DISPLAY_CONFIG_ENV: &str = "IGGY_DISPLAY_CONFIG";
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RelocatedTarget {
+ TopicOption(&'static str),
+ MovedTo(&'static str),
+ Removed,
+}
+
/// A config key that no longer exists, and what took over from it.
///
-/// Nothing else catches such a key. Its struct field is gone and no config
-/// table sets `deny_unknown_fields`, so figment drops an unrecognized key
-/// without a word from either source: a server still carrying
-/// `enforce_fsync = true` would boot reporting success and run without fsync.
-/// Every relocated key used to change behavior, so the boot is refused rather
-/// than warned about.
+/// Reject obsolete keys explicitly, including environment variables that the
+/// typed provider would otherwise ignore. These entries are rejection rules,
+/// never accepted mappings or compatibility aliases.
#[derive(Debug, Clone, Copy)]
pub struct RelocatedKey {
/// Dotted config path, for example `system.segment.size`. A deleted table
/// matches everything nested under it as well.
pub path: &'static str,
- /// The per-topic option key that replaced it, or `None` when the feature
- /// it configured was removed outright.
- pub replacement: Option<&'static str>,
+ /// The replacement location, or an explicit removal with no alias.
+ pub replacement: RelocatedTarget,
}
impl RelocatedKey {
/// Sentence telling the operator where the setting went.
fn guidance(&self) -> String {
match self.replacement {
- Some(option) => {
+ RelocatedTarget::TopicOption(option) => {
format!("it is now the per-topic '{option}' option, set on CreateTopic")
}
- None => "the feature it configured was removed".to_string(),
+ RelocatedTarget::MovedTo(path) => format!("move this setting to '{path}'"),
+ RelocatedTarget::Removed => "this setting or table was removed".to_string(),
}
}
}
@@ -66,6 +70,7 @@
display_config: bool,
env_prefix: &'static str,
relocated_keys: &'static [RelocatedKey],
+ known_env_names: Option<Vec<&'static str>>,
}
impl<P: Provider> FileConfigProvider<P> {
@@ -89,6 +94,7 @@
display_config,
env_prefix: "",
relocated_keys: &[],
+ known_env_names: None,
}
}
@@ -109,6 +115,31 @@
self
}
+ pub fn with_known_env_names(mut self, names: Vec<&'static str>) -> Self {
+ self.known_env_names = Some(names);
+ self
+ }
+
+ fn reject_unknown_env_names(&self) -> Result<(), ConfigurationError> {
+ let Some(known) = &self.known_env_names else {
+ return Ok(());
+ };
+ let unknown = unknown_env_names(
+ env::vars_os().filter_map(|(name, _)| name.into_string().ok()),
+ self.env_prefix,
+ known,
+ );
+ for name in &unknown {
+ eprintln!("Unknown configuration environment variable '{name}'");
+ }
+ let rejected = !unknown.is_empty();
+ if rejected {
+ Err(ConfigurationError::InvalidConfigurationValue)
+ } else {
+ Ok(())
+ }
+ }
+
fn reject_relocated_keys(&self) -> Result<(), ConfigurationError> {
if self.relocated_keys.is_empty() {
return Ok(());
@@ -123,7 +154,7 @@
.is_some_and(|file| file.find_value(key.path).is_ok())
{
found = true;
- error!(
+ eprintln!(
"Config key '{}' no longer exists; {}. Remove the key to boot.",
key.path,
key.guidance()
@@ -132,7 +163,7 @@
}
for (name, key) in relocated_env_vars(env_names, self.env_prefix, self.relocated_keys) {
found = true;
- error!(
+ eprintln!(
"Environment variable '{name}' sets config key '{}', which no longer exists; {}. \
Unset it to boot.",
key.path,
@@ -155,6 +186,7 @@
// below is just as silent about a key no field reads, and the
// pure-env container never touches the file branch at all.
self.reject_relocated_keys()?;
+ self.reject_unknown_env_names()?;
// Start with the default configuration if provided
let mut config_builder = Figment::new();
@@ -217,7 +249,7 @@
prefix: &str,
keys: &'keys [RelocatedKey],
) -> Vec<(String, &'keys RelocatedKey)> {
- let derived: Vec<(String, &RelocatedKey)> = keys
+ let mut derived: Vec<(String, &RelocatedKey)> = keys
.iter()
.map(|key| {
(
@@ -227,6 +259,7 @@
})
.collect();
+ derived.sort_unstable_by_key(|(name, _)| std::cmp::Reverse(name.len()));
let mut found = Vec::new();
for name in names {
for (env_name, key) in &derived {
@@ -242,6 +275,16 @@
found
}
+fn unknown_env_names(
+ names: impl Iterator<Item = String>,
+ prefix: &str,
+ known: &[&str],
+) -> Vec<String> {
+ names
+ .filter(|name| name.starts_with(prefix) && !known.contains(&name.as_str()))
+ .collect()
+}
+
fn file_exists<P: AsRef<Path>>(path: P) -> bool {
let path = path.as_ref();
@@ -272,17 +315,43 @@
mod tests {
use super::*;
+ // Intentionally obsolete inputs verify rejection, not compatibility.
const KEYS: &[RelocatedKey] = &[
RelocatedKey {
path: "system.partition.enforce_fsync",
- replacement: Some("enforce_fsync"),
+ replacement: RelocatedTarget::TopicOption("durability"),
},
RelocatedKey {
path: "system.message_deduplication",
- replacement: None,
+ replacement: RelocatedTarget::Removed,
},
];
+ #[test]
+ fn unknown_names_are_rejected_without_rejecting_known_process_settings() {
+ let unknown = unknown_env_names(
+ names(&[
+ "IGGY_ENCRYPTION_UNKNOWN",
+ "IGGY_TCP_ADDRESS",
+ "IGGY_ROOT_PASSWORD",
+ "PATH",
+ ])
+ .into_iter(),
+ "IGGY_",
+ &["IGGY_TCP_ADDRESS", "IGGY_ROOT_PASSWORD"],
+ );
+ assert_eq!(unknown, vec!["IGGY_ENCRYPTION_UNKNOWN"]);
+ }
+
+ #[test]
+ fn moved_settings_name_their_new_location() {
+ let key = RelocatedKey {
+ path: "system.encryption",
+ replacement: RelocatedTarget::MovedTo("encryption"),
+ };
+ assert_eq!(key.guidance(), "move this setting to 'encryption'");
+ }
+
fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
@@ -297,7 +366,10 @@
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC");
- assert_eq!(found[0].1.replacement, Some("enforce_fsync"));
+ assert_eq!(
+ found[0].1.replacement,
+ RelocatedTarget::TopicOption("durability")
+ );
}
#[test]
@@ -310,7 +382,7 @@
assert_eq!(found.len(), 1);
assert_eq!(found[0].1.path, "system.message_deduplication");
- assert_eq!(found[0].1.replacement, None);
+ assert_eq!(found[0].1.replacement, RelocatedTarget::Removed);
}
#[test]
diff --git a/core/configs/src/configs_impl/mod.rs b/core/configs/src/configs_impl/mod.rs
index 905cb46..b94d4dd 100644
--- a/core/configs/src/configs_impl/mod.rs
+++ b/core/configs/src/configs_impl/mod.rs
@@ -33,7 +33,7 @@
pub use env_mapping::{ConfigEnvMappings, EnvVarMapping};
pub use error::ConfigurationError;
-pub use file_provider::{FileConfigProvider, RelocatedKey};
+pub use file_provider::{FileConfigProvider, RelocatedKey, RelocatedTarget};
pub use parsing::parse_env_value_to_json;
pub use traits::{ConfigProvider, ConfigurationType};
pub use typed_env_provider::TypedEnvProvider;
diff --git a/core/configs/src/configs_impl/typed_env_provider.rs b/core/configs/src/configs_impl/typed_env_provider.rs
index 18c0194..72ac9fe 100644
--- a/core/configs/src/configs_impl/typed_env_provider.rs
+++ b/core/configs/src/configs_impl/typed_env_provider.rs
@@ -86,7 +86,7 @@
///
/// # Example
/// ```ignore
-/// let provider = TypedEnvProvider::<ServerConfig>::new("IGGY_", &["IGGY_SYSTEM_ENCRYPTION_KEY"]);
+/// let provider = TypedEnvProvider::<ServerConfig>::new("IGGY_", &["IGGY_ENCRYPTION_KEY"]);
/// ```
#[derive(Debug, Clone)]
pub struct TypedEnvProvider<T: ConfigEnvMappings> {
diff --git a/core/configs/src/lib.rs b/core/configs/src/lib.rs
index db8f674..62832e1 100644
--- a/core/configs/src/lib.rs
+++ b/core/configs/src/lib.rs
@@ -24,7 +24,7 @@
pub use configs_derive::ConfigEnv;
pub use configs_impl::{
ConfigEnvMappings, ConfigProvider, ConfigurationError, ConfigurationType, EnvVarMapping,
- FileConfigProvider, RelocatedKey, TypedEnvProvider, parse_env_value_to_json,
+ FileConfigProvider, RelocatedKey, RelocatedTarget, TypedEnvProvider, parse_env_value_to_json,
};
pub use server_config::{
cluster, message_bus, metadata, partition, quic, server, sharding, tcp, websocket,
diff --git a/core/configs/src/server_config/defaults.rs b/core/configs/src/server_config/defaults.rs
index a089139..ccf5b8d 100644
--- a/core/configs/src/server_config/defaults.rs
+++ b/core/configs/src/server_config/defaults.rs
@@ -33,7 +33,6 @@
use super::partition::PartitionConfig;
use super::quic::{QuicCertificateConfig, QuicConfig};
use super::server::ServerConfig;
-use super::server::ServerSystemConfig;
use super::tcp::{TcpConfig, TcpTlsConfig};
use super::websocket::{WebSocketConfig, WebSocketTlsConfig};
use crate::common::http::HttpConfig;
@@ -42,7 +41,6 @@
TelemetryConfig,
};
use std::num::NonZeroU32;
-use std::sync::Arc;
// Same embedded TOML the shared sections read; re-exported so sibling
// modules reach it as `super::defaults::SERVER_CONFIG`.
@@ -56,7 +54,12 @@
heartbeat: HeartbeatConfig::default(),
node: NodeConfig::default(),
personal_access_token: PersonalAccessTokenConfig::default(),
- system: Arc::new(ServerSystemConfig::default()),
+ path: SERVER_CONFIG.path.to_owned(),
+ runtime: Default::default(),
+ logging: Default::default(),
+ encryption: Default::default(),
+ memory_pool: Default::default(),
+ sharding: Default::default(),
quic: QuicConfig::default(),
tcp: TcpConfig::default(),
websocket: WebSocketConfig::default(),
@@ -181,10 +184,15 @@
// schema cannot drift (same pattern as MetadataConfig above).
let partition = &SERVER_CONFIG.partition;
PartitionConfig {
+ wal_bytes_max: partition
+ .wal_bytes_max
+ .parse()
+ .expect("embedded WAL capacity is valid"),
+ validate_checksum: SERVER_CONFIG.partition.validate_checksum,
prepare_queue_depth: partition.prepare_queue_depth as usize,
dedup_clients_max: partition.dedup_clients_max as usize,
consumer_offsets_max: partition.consumer_offsets_max as usize,
- consumer_offset_enforce_fsync: partition.consumer_offset_enforce_fsync,
+
offset_reservation_lease: NonZeroU32::new(partition.offset_reservation_lease as u32)
.expect("the embedded config.toml carries a nonzero offset_reservation_lease"),
evicted_ring_capacity: partition.evicted_ring_capacity as usize,
diff --git a/core/configs/src/server_config/displays.rs b/core/configs/src/server_config/displays.rs
index d0284e5..98bcb46 100644
--- a/core/configs/src/server_config/displays.rs
+++ b/core/configs/src/server_config/displays.rs
@@ -35,12 +35,19 @@
write!(
f,
"{{ consumer_group: {}, data_maintenance: {}, \
- heartbeat: {}, system: {}, quic: {}, tcp: {}, http: {}, telemetry: {}, \
+ heartbeat: {}, path: {}, runtime: {{ path: {} }}, logging: {}, \
+ encryption: {}, memory_pool: {:?}, sharding: {:?}, \
+ quic: {}, tcp: {}, http: {}, telemetry: {}, \
metadata: {}, message_bus: {}, partition: {} }}",
self.consumer_group,
self.data_maintenance,
self.heartbeat,
- self.system,
+ self.path,
+ self.runtime.path,
+ self.logging,
+ self.encryption,
+ self.memory_pool,
+ self.sharding,
self.quic,
self.tcp,
self.http,
@@ -56,14 +63,15 @@
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
- "{{ prepare_queue_depth: {}, dedup_clients_max: {}, consumer_offsets_max: {}, \
- consumer_offset_enforce_fsync: {}, offset_reservation_lease: {}, \
+ "{{ wal_bytes_max: {}, validate_checksum: {}, prepare_queue_depth: {}, dedup_clients_max: {}, consumer_offsets_max: {}, \
+ offset_reservation_lease: {}, \
evicted_ring_capacity: {}, evicted_ring_bytes_max: {}, \
transfer_served_cache_bytes_max: {}, transfer_artifact_bytes_max: {} }}",
+ self.wal_bytes_max,
+ self.validate_checksum,
self.prepare_queue_depth,
self.dedup_clients_max,
self.consumer_offsets_max,
- self.consumer_offset_enforce_fsync,
self.offset_reservation_lease,
self.evicted_ring_capacity,
self.evicted_ring_bytes_max,
diff --git a/core/configs/src/server_config/partition.rs b/core/configs/src/server_config/partition.rs
index 5625793..4f2c986 100644
--- a/core/configs/src/server_config/partition.rs
+++ b/core/configs/src/server_config/partition.rs
@@ -156,9 +156,24 @@
PARTITION_CONSUMER_OFFSETS_DEFAULT
}
+pub const DEFAULT_PARTITION_WAL_BYTES_MAX: u64 = 256 * 1024 * 1024;
+pub const MIN_PARTITION_WAL_BYTES_MAX: u64 = 2 * (64 * 1024 * 1024 + 4096);
+pub const MAX_PARTITION_WAL_BYTES_MAX: u64 = 4 * 1024 * 1024 * 1024;
+
+fn default_wal_bytes_max() -> IggyByteSize {
+ IggyByteSize::from(DEFAULT_PARTITION_WAL_BYTES_MAX)
+}
+
/// Capacity tunables for the per-partition consensus plane.
#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
pub struct PartitionConfig {
+ /// Active WAL and pending prepare budget per persisted partition.
+ /// Temporary generation rewrites may require additional disk space.
+ #[serde(default = "default_wal_bytes_max")]
+ #[config_env(leaf)]
+ pub wal_bytes_max: IggyByteSize,
+ #[serde(default = "default_validate_checksum")]
+ pub validate_checksum: bool,
/// Depth of a partition's prepare queue: how many uncommitted produce /
/// consumer-offset ops may be in flight at once for that partition.
/// Submits beyond it are rejected with the transient prepare-queue-full
@@ -183,14 +198,6 @@
#[serde(default = "default_consumer_offsets_max")]
pub consumer_offsets_max: usize,
- /// Whether consumer-offset files are written crash-safe: data-synced, then
- /// renamed over the prior cursor, with the directory synced once per commit
- /// walk. Independent of the topic's `enforce_fsync`, which governs message
- /// and index files. Off, an offset file is rewritten in place with no sync.
- /// A lost or torn cursor can cause replay from the earliest retained data.
- #[serde(default)]
- pub consumer_offset_enforce_fsync: bool,
-
/// Offsets claimed in the superblock ahead of the mint counter before an
/// append, so a crash-restarted replica resumes above what it confirmed.
/// One superblock write per block: lowering it raises the fsync rate,
@@ -251,6 +258,16 @@
impl Validatable<ConfigurationError> for PartitionConfig {
fn validate(&self) -> Result<(), ConfigurationError> {
+ let wal_bytes = self.wal_bytes_max.as_bytes_u64();
+ if !(MIN_PARTITION_WAL_BYTES_MAX..=MAX_PARTITION_WAL_BYTES_MAX).contains(&wal_bytes)
+ || !wal_bytes.is_multiple_of(4096)
+ {
+ eprintln!(
+ "{COMPONENT} partition.wal_bytes_max must be a 4 KiB multiple between {MIN_PARTITION_WAL_BYTES_MAX} and {MAX_PARTITION_WAL_BYTES_MAX} bytes"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+
if self.prepare_queue_depth == 0 {
eprintln!("{COMPONENT} partition.prepare_queue_depth must be > 0");
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -304,7 +321,7 @@
return Err(ConfigurationError::InvalidConfigurationValue);
}
// The FLOOR on `transfer_artifact_bytes_max` cannot live here (it needs
- // `system.segment.size` and the bus cap); it is enforced in the
+ // the topic's `segment_size` and the bus cap); it is enforced in the
// `ServerConfig` validator, which is what turns that misconfiguration
// into a boot error instead of a silent per-partition rejoin livelock.
let served_cache = self.transfer_served_cache_bytes_max.as_bytes_u64();
@@ -338,11 +355,47 @@
}
}
+const fn default_validate_checksum() -> bool {
+ true
+}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
+ fn wal_capacity_requires_aligned_bounds_and_defaults_when_omitted() {
+ for bytes in [
+ 0,
+ MIN_PARTITION_WAL_BYTES_MAX - 4096,
+ MIN_PARTITION_WAL_BYTES_MAX + 1,
+ MAX_PARTITION_WAL_BYTES_MAX + 4096,
+ ] {
+ let config = PartitionConfig {
+ wal_bytes_max: IggyByteSize::from(bytes),
+ ..PartitionConfig::default()
+ };
+ assert!(config.validate().is_err(), "accepted WAL capacity {bytes}");
+ }
+ for bytes in [
+ MIN_PARTITION_WAL_BYTES_MAX,
+ DEFAULT_PARTITION_WAL_BYTES_MAX,
+ MAX_PARTITION_WAL_BYTES_MAX,
+ ] {
+ let config = PartitionConfig {
+ wal_bytes_max: IggyByteSize::from(bytes),
+ ..PartitionConfig::default()
+ };
+ assert!(config.validate().is_ok());
+ }
+ let config: PartitionConfig = serde_json::from_str(&partial_table(None)).unwrap();
+ assert_eq!(
+ config.wal_bytes_max.as_bytes_u64(),
+ DEFAULT_PARTITION_WAL_BYTES_MAX
+ );
+ }
+
+ #[test]
fn default_impl_validates() {
// `Default` reads the shipped config.toml; the pristine deployment
// must validate.
diff --git a/core/configs/src/server_config/server.rs b/core/configs/src/server_config/server.rs
index bd79fdc..2420fcc 100644
--- a/core/configs/src/server_config/server.rs
+++ b/core/configs/src/server_config/server.rs
@@ -22,14 +22,17 @@
use super::node::NodeConfig;
use super::partition::PartitionConfig;
use super::quic::QuicConfig;
+use super::sharding::ShardingConfig;
use super::tcp::TcpConfig;
use super::websocket::WebSocketConfig;
use crate::ConfigurationError;
use crate::common::http::HttpConfig;
-use crate::common::system::SystemConfig;
+use crate::common::system::{
+ EncryptionConfig, INDEX_EXTENSION, LOG_EXTENSION, LoggingConfig, RuntimeConfig,
+};
use configs::{
ConfigEnv, ConfigEnvMappings, ConfigProvider, FileConfigProvider, RelocatedKey,
- TypedEnvProvider,
+ RelocatedTarget, TypedEnvProvider,
};
use err_trail::ErrContext;
use figment::providers::{Format, Toml};
@@ -37,8 +40,8 @@
use figment::{Metadata, Profile, Provider};
use iggy_common::Validatable;
use serde::{Deserialize, Serialize};
+use server_common::bootstrap::SystemPaths;
use std::env;
-use std::sync::Arc;
pub use crate::common::server::{
ConsumerGroupConfig, DataMaintenanceConfig, HeartbeatConfig, MemoryPoolConfig,
@@ -46,6 +49,20 @@
TelemetryConfig, TelemetryLogsConfig, TelemetryTracesConfig, TelemetryTransport,
};
+pub const SERVER_PROCESS_ENV_VARS: &[&str] = &[
+ "IGGY_CONFIG_PATH",
+ "IGGY_ENV_PATH",
+ "IGGY_DISPLAY_CONFIG",
+ "IGGY_ROOT_USERNAME",
+ "IGGY_ROOT_PASSWORD",
+ "IGGY_TEST_VERBOSE",
+ "IGGY_TEST_CLUSTER_NODES",
+ "IGGY_TEST_CLEANUP_DISABLED",
+ "IGGY_SHARD_RUNTIME_CAPACITY",
+ "IGGY_SHARD_EVENT_INTERVAL",
+ "IGGY_CI_BUILD",
+];
+
const DEFAULT_CONFIG_PATH: &str = "core/server/config.toml";
/// Server config keys that became per-topic options, or went away with the
@@ -56,51 +73,64 @@
/// enough. The partition knobs matter most: they are create-only options now,
/// so a topic that boots without one can never be given it afterwards.
const RELOCATED_CONFIG_KEYS: &[RelocatedKey] = &[
+ // Refuse obsolete layout overrides instead of silently reading another directory.
RelocatedKey {
- path: "system.topic.max_size",
- replacement: Some("max_topic_size"),
+ path: "partition.path",
+ replacement: RelocatedTarget::Removed,
},
RelocatedKey {
- path: "system.topic.message_expiry",
- replacement: Some("message_expiry"),
+ path: "system.path",
+ replacement: RelocatedTarget::MovedTo("path"),
},
RelocatedKey {
- path: "system.partition.enforce_fsync",
- replacement: Some("enforce_fsync"),
+ path: "system.runtime",
+ replacement: RelocatedTarget::MovedTo("runtime"),
},
RelocatedKey {
- path: "system.partition.messages_required_to_save",
- replacement: Some("messages_required_to_save"),
+ path: "system.logging",
+ replacement: RelocatedTarget::MovedTo("logging"),
},
RelocatedKey {
- path: "system.partition.size_of_messages_required_to_save",
- replacement: Some("size_of_messages_required_to_save"),
+ path: "system.encryption",
+ replacement: RelocatedTarget::MovedTo("encryption"),
},
RelocatedKey {
- path: "system.segment.size",
- replacement: Some("segment_size"),
+ path: "system.partition",
+ replacement: RelocatedTarget::MovedTo("partition"),
},
RelocatedKey {
- path: "system.segment.preallocate",
- replacement: Some("preallocate_segments"),
+ path: "system.sharding",
+ replacement: RelocatedTarget::MovedTo("sharding"),
},
RelocatedKey {
- path: "system.message_deduplication",
- replacement: None,
+ path: "system.memory_pool",
+ replacement: RelocatedTarget::MovedTo("memory_pool"),
+ },
+ RelocatedKey {
+ path: "stream",
+ replacement: RelocatedTarget::Removed,
+ },
+ RelocatedKey {
+ path: "topic",
+ replacement: RelocatedTarget::Removed,
+ },
+ // Reject the removed table and every former environment mapping beneath it.
+ RelocatedKey {
+ path: "system",
+ replacement: RelocatedTarget::Removed,
+ },
+ RelocatedKey {
+ path: "partition.consumer_offset_enforce_fsync",
+ replacement: RelocatedTarget::TopicOption("consumer_offset_durability"),
},
// The whole table, not just its leaves. Caps are compile-time constants
// enforced at admission, so a per-node value could only diverge from them.
RelocatedKey {
path: "extra",
- replacement: None,
+ replacement: RelocatedTarget::Removed,
},
];
-/// [`SystemConfig`] bound to this crate's own
-/// [`super::sharding::ShardingConfig`]. `core/server` names this alias
-/// wherever it refers to the system config.
-pub type ServerSystemConfig = SystemConfig<super::sharding::ShardingConfig>;
-
/// Top-level on-disk config schema for the `iggy-server` binary.
///
/// Composes the shared section types from `crate::common` with the
@@ -108,6 +138,7 @@
/// by `super`.
#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
#[config_env(prefix = "IGGY_", name = "iggy-server-config")]
+#[serde(deny_unknown_fields)]
pub struct ServerConfig {
pub consumer_group: ConsumerGroupConfig,
pub data_maintenance: DataMaintenanceConfig,
@@ -116,7 +147,12 @@
#[serde(default)]
pub personal_access_token: PersonalAccessTokenConfig,
pub heartbeat: HeartbeatConfig,
- pub system: Arc<ServerSystemConfig>,
+ pub path: String,
+ pub runtime: RuntimeConfig,
+ pub logging: LoggingConfig,
+ pub encryption: EncryptionConfig,
+ pub memory_pool: MemoryPoolConfig,
+ pub sharding: ShardingConfig,
pub quic: QuicConfig,
pub tcp: TcpConfig,
pub http: HttpConfig,
@@ -219,6 +255,12 @@
Some(default_config),
)
.with_relocated_keys(ServerConfig::ENV_PREFIX, RELOCATED_CONFIG_KEYS)
+ .with_known_env_names(
+ Self::all_env_var_names()
+ .into_iter()
+ .chain(SERVER_PROCESS_ENV_VARS.iter().copied())
+ .collect(),
+ )
}
/// All recognised env var names for [`ServerConfig`].
@@ -258,6 +300,158 @@
}
}
+impl ServerConfig {
+ pub fn get_system_path(&self) -> String {
+ self.path.to_string()
+ }
+
+ pub fn get_state_path(&self) -> String {
+ format!("{}/state", self.get_system_path())
+ }
+
+ pub fn get_state_messages_file_path(&self) -> String {
+ format!("{}/log", self.get_state_path())
+ }
+
+ pub fn get_state_info_path(&self) -> String {
+ format!("{}/info", self.get_state_path())
+ }
+ pub fn get_state_tokens_path(&self) -> String {
+ format!("{}/tokens", self.get_state_path())
+ }
+
+ pub fn get_runtime_path(&self) -> String {
+ format!("{}/{}", self.get_system_path(), self.runtime.path)
+ }
+
+ pub fn get_streams_path(&self) -> String {
+ format!("{}/streams", self.get_system_path())
+ }
+
+ pub fn get_stream_path(&self, stream_id: usize) -> String {
+ format!("{}/{}", self.get_streams_path(), stream_id)
+ }
+
+ pub fn get_topics_path(&self, stream_id: usize) -> String {
+ format!("{}/topics", self.get_stream_path(stream_id))
+ }
+
+ pub fn get_topic_path(&self, stream_id: usize, topic_id: usize) -> String {
+ format!("{}/{}", self.get_topics_path(stream_id), topic_id)
+ }
+
+ pub fn get_partitions_path(&self, stream_id: usize, topic_id: usize) -> String {
+ format!("{}/partitions", self.get_topic_path(stream_id, topic_id))
+ }
+
+ pub fn get_partition_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ ) -> String {
+ format!(
+ "{}/{}",
+ self.get_partitions_path(stream_id, topic_id),
+ partition_id
+ )
+ }
+
+ pub fn get_offsets_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ ) -> String {
+ format!(
+ "{}/offsets",
+ self.get_partition_path(stream_id, topic_id, partition_id)
+ )
+ }
+
+ pub fn get_consumer_offsets_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ ) -> String {
+ format!(
+ "{}/consumers",
+ self.get_offsets_path(stream_id, topic_id, partition_id)
+ )
+ }
+
+ pub fn get_consumer_group_offsets_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ ) -> String {
+ format!(
+ "{}/groups",
+ self.get_offsets_path(stream_id, topic_id, partition_id)
+ )
+ }
+
+ pub fn get_segment_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ start_offset: u64,
+ ) -> String {
+ format!(
+ "{}/{:0>20}",
+ self.get_partition_path(stream_id, topic_id, partition_id),
+ start_offset
+ )
+ }
+
+ pub fn get_messages_file_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ start_offset: u64,
+ ) -> String {
+ let path = self.get_segment_path(stream_id, topic_id, partition_id, start_offset);
+ format!("{path}.{LOG_EXTENSION}")
+ }
+
+ pub fn get_index_path(
+ &self,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ start_offset: u64,
+ ) -> String {
+ let path = self.get_segment_path(stream_id, topic_id, partition_id, start_offset);
+ format!("{path}.{INDEX_EXTENSION}")
+ }
+}
+
+impl SystemPaths for ServerConfig {
+ fn get_system_path(&self) -> String {
+ ServerConfig::get_system_path(self)
+ }
+
+ fn get_state_path(&self) -> String {
+ ServerConfig::get_state_path(self)
+ }
+
+ fn get_state_messages_file_path(&self) -> String {
+ ServerConfig::get_state_messages_file_path(self)
+ }
+
+ fn get_streams_path(&self) -> String {
+ ServerConfig::get_streams_path(self)
+ }
+
+ fn get_runtime_path(&self) -> String {
+ ServerConfig::get_runtime_path(self)
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -293,6 +487,19 @@
}
#[test]
+ fn data_root_uses_fixed_stream_topic_and_partition_directories() {
+ let config = ServerConfig {
+ path: "/var/lib/iggy".to_owned(),
+ ..ServerConfig::default()
+ };
+ assert_eq!(
+ config.get_partition_path(1, 2, 3),
+ "/var/lib/iggy/streams/1/topics/2/partitions/3"
+ );
+ assert!(!ServerConfig::all_env_var_names().contains(&"IGGY_PARTITION_PATH"));
+ }
+
+ #[test]
fn all_env_var_names_include_message_bus_section() {
let names = ServerConfig::all_env_var_names();
assert!(
diff --git a/core/configs/src/server_config/sharding.rs b/core/configs/src/server_config/sharding.rs
index 9f4cead..c7f6335 100644
--- a/core/configs/src/server_config/sharding.rs
+++ b/core/configs/src/server_config/sharding.rs
@@ -63,10 +63,10 @@
pub const RECONCILE_PERIODIC_INTERVAL_MAX: Duration = Duration::from_secs(30);
// Every omitted field falls back to the frozen `Default`, so a partial
-// `[system.sharding]` table resolves each key independently instead of
+// `[sharding]` table resolves each key independently instead of
// failing on the first missing one (parity with the legacy type).
#[serde_as]
-#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
#[serde(default)]
pub struct ShardingConfig {
#[serde(default)]
@@ -139,29 +139,25 @@
fn default() -> Self {
Self {
cpu_allocation: CpuAllocation::default(),
- pin_cores: SERVER_CONFIG.system.sharding.pin_cores,
- inbox_capacity: SERVER_CONFIG.system.sharding.inbox_capacity as usize,
- reply_inbox_capacity: SERVER_CONFIG.system.sharding.reply_inbox_capacity as usize,
+ pin_cores: SERVER_CONFIG.sharding.pin_cores,
+ inbox_capacity: SERVER_CONFIG.sharding.inbox_capacity as usize,
+ reply_inbox_capacity: SERVER_CONFIG.sharding.reply_inbox_capacity as usize,
shutdown_drain_timeout: SERVER_CONFIG
- .system
.sharding
.shutdown_drain_timeout
.parse()
.unwrap(),
shutdown_poll_interval: SERVER_CONFIG
- .system
.sharding
.shutdown_poll_interval
.parse()
.unwrap(),
shutdown_join_timeout: SERVER_CONFIG
- .system
.sharding
.shutdown_join_timeout
.parse()
.unwrap(),
reconcile_periodic_interval: SERVER_CONFIG
- .system
.sharding
.reconcile_periodic_interval
.parse()
@@ -395,7 +391,7 @@
.expect("embedded TOML deserializes");
config.validate().expect("embedded config validates");
- let sharding = &config.system.sharding;
+ let sharding = &config.sharding;
assert!(sharding.pin_cores);
assert_eq!(sharding.inbox_capacity, 1024);
assert_eq!(sharding.reply_inbox_capacity, 1024);
diff --git a/core/configs/src/server_config/validators.rs b/core/configs/src/server_config/validators.rs
index a20a48b..1bdb444 100644
--- a/core/configs/src/server_config/validators.rs
+++ b/core/configs/src/server_config/validators.rs
@@ -41,8 +41,7 @@
impl Validatable<ConfigurationError> for ServerConfig {
fn validate(&self) -> Result<(), ConfigurationError> {
- self.system
- .memory_pool
+ self.memory_pool
.validate()
.error(|e: &ConfigurationError| {
format!("{COMPONENT} (error: {e}) - failed to validate memory pool config")
@@ -59,21 +58,12 @@
"{COMPONENT} (error: {e}) - failed to validate personal access token config"
)
})?;
- self.system
- .segment
- .validate()
- .error(|e: &ConfigurationError| {
- format!("{COMPONENT} (error: {e}) - failed to validate segment config")
- })?;
self.telemetry.validate().error(|e: &ConfigurationError| {
format!("{COMPONENT} (error: {e}) - failed to validate telemetry config")
})?;
- self.system
- .sharding
- .validate()
- .error(|e: &ConfigurationError| {
- format!("{COMPONENT} (error: {e}) - failed to validate sharding config")
- })?;
+ self.sharding.validate().error(|e: &ConfigurationError| {
+ format!("{COMPONENT} (error: {e}) - failed to validate sharding config")
+ })?;
self.cluster.validate().error(|e: &ConfigurationError| {
format!("{COMPONENT} (error: {e}) - failed to validate cluster config")
})?;
@@ -88,12 +78,9 @@
self.partition.validate().error(|e: &ConfigurationError| {
format!("{COMPONENT} (error: {e}) - failed to validate partition config")
})?;
- self.system
- .logging
- .validate()
- .error(|e: &ConfigurationError| {
- format!("{COMPONENT} (error: {e}) - failed to validate logging config")
- })?;
+ self.logging.validate().error(|e: &ConfigurationError| {
+ format!("{COMPONENT} (error: {e}) - failed to validate logging config")
+ })?;
if self.http.enabled
&& let IggyExpiry::ServerDefault = self.http.jwt.access_token_expiry
@@ -376,8 +363,6 @@
return Err(ConfigurationError::InvalidConfigurationValue);
}
- reject_unsupported(self)?;
-
Ok(())
}
}
@@ -414,22 +399,6 @@
(resident_len, slots)
}
-/// The server parses the whole config surface but does not yet honor every
-/// knob. Make the still-inert ones loud at boot rather than silently ignored.
-/// All are off by default, so only a deliberate opt-in trips this.
-fn reject_unsupported(config: &ServerConfig) -> Result<(), ConfigurationError> {
- if config.system.segment.archive_expired {
- eprintln!("system.segment.archive_expired is not supported");
- return Err(ConfigurationError::InvalidConfigurationValue);
- }
- if config.system.recovery.recreate_missing_state {
- eprintln!("system.recovery.recreate_missing_state is not supported");
- return Err(ConfigurationError::InvalidConfigurationValue);
- }
-
- Ok(())
-}
-
impl ServerConfig {
fn validate_tcp_bind_address(&self) -> Result<(), ConfigurationError> {
parse_bind_address("tcp.address", &self.tcp.address)?;
@@ -621,18 +590,6 @@
}
#[test]
- fn given_archive_expired_enabled_when_validating_should_reject() {
- let config = config_with_override("[system.segment]\narchive_expired = true\n");
- assert!(config.validate().is_err());
- }
-
- #[test]
- fn given_recreate_missing_state_enabled_when_validating_should_reject() {
- let config = config_with_override("[system.recovery]\nrecreate_missing_state = true\n");
- assert!(config.validate().is_err());
- }
-
- #[test]
fn given_asymmetric_jwt_algorithm_when_validating_should_reject() {
// Boots clean today and then fails every login: the key is HMAC and
// jsonwebtoken refuses a mismatched algorithm family at sign time.
diff --git a/core/connectors/sdk/Cargo.toml b/core/connectors/sdk/Cargo.toml
index 3d511d3..070ab8c 100644
--- a/core/connectors/sdk/Cargo.toml
+++ b/core/connectors/sdk/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy_connector_sdk"
-version = "0.4.0-edge.4"
+version = "0.4.0-edge.5"
description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
edition = "2024"
license = "Apache-2.0"
diff --git a/core/connectors/sdk/README.md b/core/connectors/sdk/README.md
index 96f8037..dbb284b 100644
--- a/core/connectors/sdk/README.md
+++ b/core/connectors/sdk/README.md
@@ -25,9 +25,11 @@
| After send success but before state persistence | Persisted state is unchanged, so the batch may be delivered again. |
| After state persistence but before the plugin processes the ACK | The restored state records the delivered batch. Deferred source-side cleanup may still be pending. |
| After the plugin processes the ACK | The state and plugin cursor both record the delivered batch. |
-| After an in-memory confirmation and source cleanup, but before Iggy fsyncs | A server crash can lose the batch after source cleanup unless server-side `enforce_fsync` is enabled. |
+| After a replicated confirmation and source cleanup, but before stable storage | Losing the replicas holding the unpersisted tail can lose the batch. Create the topic with `durability=persisted` when source cleanup requires durable quorum confirmation. |
-An ACK means that Iggy confirmed the batch in memory; durability depends on the server's `enforce_fsync` setting. Source-side ACK work should be idempotent because process termination can interrupt it. NACK handling must discard staged cursor changes and staged delete or mark operations so polling can redeliver the batch. The SDK retries NACKed batches with capped exponential backoff and stops after repeated NACKs.
+An ACK follows Iggy's quorum confirmation. The topic's `durability` policy decides whether that confirmation also waits for stable storage on the quorum. Both policies normally write messages to disk.
+
+Source-side ACK work should be idempotent because process termination can interrupt it. NACK handling must discard staged cursor changes and staged delete or mark operations so polling can redeliver the batch. The SDK retries NACKed batches with capped exponential backoff and stops after repeated NACKs.
The default `Source::on_batch_result()` implementation is a no-op for sources without staged work. Sources that advance cursors, delete rows, or mark rows must override it. The SDK stops polling if the handler returns an error, preventing a failed rollback from advancing to another batch.
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 0d1ebce..4a2ae97 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -2523,9 +2523,8 @@
/// IS the whole quorum. `PrepareOk` loops through the loopback because it
/// genuinely is a message to a peer that happens to be this replica.
///
- /// The three callers that used to inline this sequence were the actual
- /// duplication: an election timeout, an SVC for a higher view, and a DVC for
- /// a higher view, differing only in `reason`.
+ /// A timed-out primary candidate can become a backup in the next view,
+ /// so every transition must restart its probe for a missing `StartView`.
fn enter_view_change(
&self,
plane: PlaneKind,
@@ -2640,43 +2639,11 @@
return Vec::new();
}
- // Escalate: try next view
- let old_view = self.view.get();
- let next_view = old_view + 1;
-
- self.view.set(next_view);
- self.reset_view_change_state();
- self.sent_own_start_view_change.set(true);
- self.start_view_change_from_all_replicas
- .borrow_mut()
- .insert(self.replica as usize);
-
- self.timeouts
- .borrow_mut()
- .reset(TimeoutKind::ViewChangeStatus);
-
- emit_sim_event(
- SimEventKind::ViewChangeStarted,
- &ViewChangeLogEvent {
- replica: ReplicaLogContext::from_consensus(self, plane),
- old_view,
- new_view: next_view,
- reason: ViewChangeReason::ViewChangeStatusTimeout,
- },
- );
-
- let action = VsrAction::SendStartViewChange {
- view: next_view,
- group: self.group,
- };
- emit_sim_event(
- SimEventKind::ControlMessageScheduled,
- &ControlActionLogEvent::from_vsr_action(
- ReplicaLogContext::from_consensus(self, plane),
- &action,
- ),
- );
- vec![action]
+ self.enter_view_change(
+ plane,
+ self.view.get() + 1,
+ ViewChangeReason::ViewChangeStatusTimeout,
+ )
}
/// Collect uncommitted pipeline entries that should be retransmitted.
@@ -3981,22 +3948,28 @@
/// Send a message to `target`, routing self-addressed messages through the loopback queue.
// VsrConsensus uses Cell/RefCell for single-threaded compio shards; futures are intentionally !Send.
#[allow(clippy::future_not_send)]
- pub(crate) async fn send_or_loopback(&self, target: u8, message: Message<GenericHeader>)
+ pub(crate) async fn send_or_loopback(&self, target: u8, message: Message<GenericHeader>) -> bool
where
B: MessageBus,
{
if target == self.replica {
self.push_loopback(message);
- } else if let Err(e) = self
+ return true;
+ }
+ match self
.message_bus
.send_to_replica(target, message.into_frozen())
.await
{
- tracing::warn!(
- replica = self.replica,
- target,
- "send_or_loopback failed: {e}"
- );
+ Ok(()) => true,
+ Err(error) => {
+ tracing::warn!(
+ replica = self.replica,
+ target,
+ "send_or_loopback failed: {error}"
+ );
+ false
+ }
}
}
@@ -5024,6 +4997,114 @@
);
}
+ #[test]
+ fn given_a_stalled_candidate_when_it_escalates_should_recover_a_lost_start_view() {
+ const REJOINING_REPLICA: u8 = 1;
+ const NEXT_PRIMARY: u8 = 2;
+ const REPLICA_COUNT: u8 = 3;
+ const LOCAL_HEAD: u64 = 20;
+ const PRIMARY_HEAD: u64 = LOCAL_HEAD + 1;
+ let plane = PlaneKind::Partitions;
+ let group = IggyNamespace::new(0, 0, 0).inner();
+ let backup = VsrConsensus::new(
+ 1,
+ REJOINING_REPLICA,
+ REPLICA_COUNT,
+ group,
+ StageNoopBus,
+ LocalPipeline::new(),
+ );
+ backup.sequencer().set_sequence(LOCAL_HEAD);
+ backup.restore_commit_state(LOCAL_HEAD - 1, LOCAL_HEAD - 1);
+ backup.begin_view_probe();
+ for _ in 0..TimeoutManager::REQUEST_START_VIEW_MESSAGE_TICKS * u64::from(PROBE_ATTEMPTS_MAX)
+ {
+ backup.tick(plane);
+ }
+ assert_eq!(backup.view(), 1);
+ assert_eq!(backup.status(), Status::ViewChange);
+ assert!(backup.is_primary_for_view(backup.view()));
+
+ for _ in 0..TimeoutManager::VIEW_CHANGE_STATUS_TICKS {
+ backup.tick(plane);
+ }
+ assert_eq!(backup.view(), 2);
+ assert_eq!(backup.status(), Status::ViewChange);
+ assert!(!backup.is_primary_for_view(backup.view()));
+
+ // Model a settled primary whose initial StartView was withheld by persistence.
+ let mut primary = VsrConsensus::new(
+ 1,
+ NEXT_PRIMARY,
+ REPLICA_COUNT,
+ group,
+ StageNoopBus,
+ LocalPipeline::new(),
+ );
+ primary.set_view(backup.view());
+ primary.set_log_view(backup.view());
+ primary.sequencer().set_sequence(PRIMARY_HEAD);
+ primary.restore_commit_state(LOCAL_HEAD, LOCAL_HEAD);
+ primary.init();
+
+ let probe = (0..TimeoutManager::REQUEST_START_VIEW_MESSAGE_TICKS)
+ .flat_map(|_| backup.tick(plane))
+ .find_map(|action| match action {
+ VsrAction::SendRequestStartView { view, group } => Some(
+ Message::<RequestStartViewHeader>::new(size_of::<RequestStartViewHeader>())
+ .transmute_header(|_, header: &mut RequestStartViewHeader| {
+ header.command = Command::RequestStartView;
+ header.cluster = 1;
+ header.replica = REJOINING_REPLICA;
+ header.view = view;
+ header.group = group;
+ header.size =
+ u32::try_from(size_of::<RequestStartViewHeader>()).unwrap();
+ header.seal();
+ }),
+ ),
+ _ => None,
+ })
+ .expect("an escalated candidate must request the missing StartView as a backup");
+ let replies = primary.handle_request_start_view(plane, probe.header());
+ let [
+ VsrAction::SendStartView {
+ view,
+ op,
+ commit,
+ incarnation,
+ target,
+ group,
+ suffix,
+ },
+ ] = replies.as_slice()
+ else {
+ panic!("the settled primary must answer the backup's probe: {replies:?}");
+ };
+ assert_eq!(*target, Some(REJOINING_REPLICA));
+ assert!(suffix.is_empty());
+ let response = Message::<StartViewHeader>::new(size_of::<StartViewHeader>())
+ .transmute_header(|_, header: &mut StartViewHeader| {
+ header.command = Command::StartView;
+ header.cluster = 1;
+ header.replica = NEXT_PRIMARY;
+ header.view = *view;
+ header.op = *op;
+ header.commit = *commit;
+ header.incarnation = *incarnation;
+ header.group = *group;
+ header.size = u32::try_from(size_of::<StartViewHeader>()).unwrap();
+ header.seal();
+ });
+ backup.handle_start_view(plane, response.header(), &[]);
+ assert_eq!(backup.status(), Status::Normal);
+ assert_eq!(backup.view(), primary.view());
+ assert_eq!(backup.log_view(), primary.log_view());
+ assert_eq!(backup.sequencer().current_sequence(), PRIMARY_HEAD);
+ assert_eq!(backup.commit_max(), LOCAL_HEAD);
+ assert_eq!(backup.commit_min(), LOCAL_HEAD - 1);
+ }
+
// A genuine current primary (no newer view seen) keeps heartbeating.
#[test]
fn current_primary_keeps_heartbeating() {
diff --git a/core/consensus/src/plane_helpers.rs b/core/consensus/src/plane_helpers.rs
index a72995c..cb9605c 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -129,7 +129,7 @@
header.op <= consensus.commit_min()
}
-/// Shared chain-replication forwarding to the next replica.
+/// Shared chain-replication forwarding, skipping disconnected replicas.
///
/// Borrows the message, makes a deep copy for the wire, and lets the caller
/// retain ownership for journal append.
@@ -137,8 +137,8 @@
/// # Errors
///
/// Returns an error if the prepare cannot be routed or the bus cannot deliver
-/// it to the next replica.
-/// Callers decide error policy (VSR retransmits from WAL via prepare timeout).
+/// it to a connected replica before the end of the chain.
+/// Other transport errors remain covered by VSR prepare retransmission.
#[allow(clippy::future_not_send)]
pub async fn replicate_to_next_in_chain<B, P>(
consensus: &VsrConsensus<B, P>,
@@ -152,20 +152,22 @@
return Ok(());
};
let frozen = message.deep_copy().into_generic().into_frozen();
- consensus
- .message_bus()
- .send_to_replica(next, frozen)
- .await
- .map_err(Into::into)
+ forward_to_connected_replica(
+ consensus,
+ frozen,
+ next,
+ consensus.primary_index(message.header().view),
+ )
+ .await
}
/// Forward an already validated frozen prepare to the next replica without
-/// copying its payload.
+/// copying its payload, skipping disconnected replicas.
///
/// # Errors
///
/// Returns an error if the frame is malformed, cannot be routed, or the bus
-/// cannot deliver it to the next replica.
+/// cannot deliver it to a connected replica before the end of the chain.
#[allow(clippy::future_not_send)]
pub async fn replicate_frozen_to_next_in_chain<B, P>(
consensus: &VsrConsensus<B, P>,
@@ -179,11 +181,42 @@
let Some(next) = replication_target(consensus, &header)? else {
return Ok(());
};
- consensus
- .message_bus()
- .send_to_replica(next, message)
- .await
- .map_err(Into::into)
+ forward_to_connected_replica(
+ consensus,
+ message,
+ next,
+ consensus.primary_index(header.view),
+ )
+ .await
+}
+
+#[allow(clippy::future_not_send)]
+async fn forward_to_connected_replica<B, P>(
+ consensus: &VsrConsensus<B, P>,
+ message: Frozen<MESSAGE_ALIGN>,
+ mut next: u8,
+ primary: u8,
+) -> Result<(), ChainReplicationError>
+where
+ B: MessageBus,
+ P: Pipeline<Entry = PipelineEntry>,
+{
+ loop {
+ match consensus
+ .message_bus()
+ .send_to_replica(next, message.clone())
+ .await
+ {
+ Ok(()) => return Ok(()),
+ Err(error @ (SendError::ReplicaNotConnected(_) | SendError::ConnectionClosed)) => {
+ next = (next + 1) % consensus.replica_count();
+ if next == primary {
+ return Err(error.into());
+ }
+ }
+ Err(error) => return Err(error.into()),
+ }
+ }
}
fn frozen_prepare_header(
@@ -561,7 +594,7 @@
/// `debug`, not `warn`, matching `tick_partitions` / `tick_metadata`: a hold is the
/// steady state for a whole rejoin, so `warn` is one line per group per tick. A
/// hold that never clears is caught by a simulator invariant, not by this line.
-fn report_uncommittable_head(
+pub fn report_uncommittable_head(
replica: u8,
head_op: u64,
commit_min: u64,
@@ -905,7 +938,8 @@
/// consensus is sans-io and cannot consult the journal itself, so the plane
/// that owns the journal must vouch that this exact prepare is durable before
/// the ack leaves. `false` withholds the ack; the primary's retransmit
-/// re-drives it once a later persist succeeds.
+/// re-drives it once a later persist succeeds. Returns `true` only after the
+/// acknowledgment is queued for delivery.
///
/// # Panics
/// - If `header.command` is not `Command::Prepare`.
@@ -915,22 +949,23 @@
consensus: &VsrConsensus<B, P>,
header: &PrepareHeader,
is_persisted: bool,
-) where
+) -> bool
+where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
assert_eq!(header.command, Command::Prepare);
if consensus.status() != Status::Normal {
- return;
+ return false;
}
if consensus.is_transferring() {
- return;
+ return false;
}
if !is_persisted {
- return;
+ return false;
}
assert!(
@@ -941,7 +976,7 @@
);
if header.op > consensus.sequencer().current_sequence() {
- return;
+ return false;
}
let prepare_ok_header = PrepareOkHeader {
@@ -972,7 +1007,7 @@
consensus
.send_or_loopback(primary, message.into_generic())
- .await;
+ .await
}
#[cfg(test)]
@@ -1273,6 +1308,118 @@
}
#[test]
+ fn given_disconnected_chain_peers_when_forwarding_should_reach_the_next_peer_immediately() {
+ for frozen in [false, true] {
+ for (replica, count, view, disconnected, expected) in [
+ (0, 3, 0, vec![1], 2),
+ (1, 3, 1, vec![2], 0),
+ (2, 5, 1, vec![3, 4], 0),
+ ] {
+ let consensus =
+ VsrConsensus::new(1, replica, count, 0, SpyBus::new(), LocalPipeline::new());
+ consensus.init();
+ let bus = consensus.message_bus();
+ for peer in &disconnected {
+ bus.failures
+ .borrow_mut()
+ .insert(*peer, SendError::ReplicaNotConnected(*peer));
+ }
+ let message = prepare_message(1, 0, 42).transmute_header(
+ |old, header: &mut PrepareHeader| {
+ *header = old;
+ header.view = view;
+ },
+ );
+ let result = if frozen {
+ let prepare = message.deep_copy().into_frozen();
+ let pointer = prepare.as_slice().as_ptr();
+ let result = futures::executor::block_on(replicate_frozen_to_next_in_chain(
+ &consensus, prepare,
+ ));
+ if let Some((_, sent)) = bus.sent.borrow().first() {
+ assert_eq!(
+ sent.as_slice().as_ptr(),
+ pointer,
+ "forwarding must not copy the payload"
+ );
+ }
+ result
+ } else {
+ futures::executor::block_on(replicate_to_next_in_chain(&consensus, &message))
+ };
+ result.expect("a disconnected peer must not delay forwarding to the live suffix");
+ assert_eq!(
+ bus.attempts.borrow().as_slice(),
+ [disconnected, vec![expected]].concat()
+ );
+ let sent = bus.sent.borrow();
+ assert_eq!(sent.len(), 1);
+ assert_eq!(sent[0].0, expected);
+ assert_eq!(sent[0].1.as_slice(), message.as_slice());
+ assert_eq!(
+ consensus.commit_max(),
+ 0,
+ "forwarding is not an acknowledgement"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn given_a_chain_boundary_when_forwarding_should_never_wrap_to_the_primary() {
+ for (replica, count, connected, expected_attempts) in [
+ (0, 3, true, vec![1]),
+ (0, 3, false, vec![1, 2]),
+ (1, 3, false, vec![2]),
+ (2, 3, false, vec![]),
+ (0, 1, false, vec![]),
+ ] {
+ let consensus =
+ VsrConsensus::new(1, replica, count, 0, SpyBus::new(), LocalPipeline::new());
+ consensus.init();
+ consensus.message_bus().reject_sends.set(!connected);
+ let result = futures::executor::block_on(replicate_frozen_to_next_in_chain(
+ &consensus,
+ prepare_message(1, 0, 42).into_frozen(),
+ ));
+ assert_eq!(result.is_ok(), connected || expected_attempts.is_empty());
+ assert_eq!(
+ *consensus.message_bus().attempts.borrow(),
+ expected_attempts
+ );
+ }
+ }
+
+ #[test]
+ fn given_a_transport_error_when_forwarding_should_skip_only_disconnected_peers() {
+ for error in [
+ SendError::ConnectionClosed,
+ SendError::Backpressure,
+ SendError::ReplicaRouteMissing(1),
+ SendError::ReplicaForwardFailed(1),
+ SendError::BusShuttingDown,
+ ] {
+ let skip = matches!(error, SendError::ConnectionClosed);
+ let consensus = VsrConsensus::new(1, 0, 3, 0, SpyBus::new(), LocalPipeline::new());
+ consensus.init();
+ consensus
+ .message_bus()
+ .failures
+ .borrow_mut()
+ .insert(1, error);
+ let result = futures::executor::block_on(replicate_frozen_to_next_in_chain(
+ &consensus,
+ prepare_message(1, 0, 42).into_frozen(),
+ ));
+ assert_eq!(result.is_ok(), skip);
+ assert_eq!(
+ consensus.message_bus().attempts.borrow().as_slice(),
+ if skip { &[1, 2][..] } else { &[1][..] },
+ );
+ }
+ }
+
+ #[test]
fn given_committed_prepare_when_selecting_replication_target_should_reject() {
let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
consensus.init();
@@ -2028,14 +2175,43 @@
);
}
+ #[test]
+ fn send_prepare_ok_reports_transport_failure_and_can_be_retried() {
+ let consensus = VsrConsensus::new(1, 1, 3, 0, SpyBus::new(), LocalPipeline::new());
+ consensus.init();
+ let header = PrepareHeader {
+ command: Command::Prepare,
+ cluster: 1,
+ checksum: 42,
+ ..Default::default()
+ };
+ consensus.message_bus().reject_sends.set(true);
+ assert!(!futures::executor::block_on(send_prepare_ok(
+ &consensus, &header, true,
+ )));
+ assert!(consensus.message_bus().sent.borrow().is_empty());
+
+ consensus.message_bus().reject_sends.set(false);
+ assert!(futures::executor::block_on(send_prepare_ok(
+ &consensus, &header, true,
+ )));
+ assert_eq!(consensus.message_bus().sent.borrow().len(), 1);
+ }
+
struct SpyBus {
sent: std::cell::RefCell<Vec<(u8, Frozen<MESSAGE_ALIGN>)>>,
+ reject_sends: std::cell::Cell<bool>,
+ failures: std::cell::RefCell<BTreeMap<u8, SendError>>,
+ attempts: std::cell::RefCell<Vec<u8>>,
}
impl SpyBus {
fn new() -> Self {
Self {
sent: std::cell::RefCell::new(Vec::new()),
+ reject_sends: std::cell::Cell::new(false),
+ failures: std::cell::RefCell::new(BTreeMap::new()),
+ attempts: std::cell::RefCell::new(Vec::new()),
}
}
}
@@ -2056,6 +2232,13 @@
replica: u8,
data: Frozen<MESSAGE_ALIGN>,
) -> Result<(), SendError> {
+ self.attempts.borrow_mut().push(replica);
+ if self.reject_sends.get() {
+ return Err(SendError::ReplicaNotConnected(replica));
+ }
+ if let Some(error) = self.failures.borrow_mut().remove(&replica) {
+ return Err(error);
+ }
self.sent.borrow_mut().push((replica, data));
Ok(())
}
diff --git a/core/harness_derive/src/attrs.rs b/core/harness_derive/src/attrs.rs
index 136b379..64c8da5 100644
--- a/core/harness_derive/src/attrs.rs
+++ b/core/harness_derive/src/attrs.rs
@@ -766,7 +766,7 @@
fn parse_dot_notation_deep() {
let attrs: IggyTestAttrs = syn::parse_quote!(server(
metadata.journal_slots = [512, 1024],
- system.encryption.enabled = true
+ encryption.enabled = true
));
assert_eq!(attrs.server.config_overrides.len(), 2);
let msgs = attrs
diff --git a/core/integration/src/harness/config/resolve.rs b/core/integration/src/harness/config/resolve.rs
index fbb5360..10994ce 100644
--- a/core/integration/src/harness/config/resolve.rs
+++ b/core/integration/src/harness/config/resolve.rs
@@ -25,12 +25,7 @@
/// `ServerConfig::all_env_var_names` cannot know them. `IGGY_CONFIG_PATH`
/// selects the config file itself and the root credentials are consumed by
/// `args.rs` before the config loads; `IGGY_TEST_VERBOSE` is harness-only.
-pub const NON_CONFIG_ENV_VARS: [&str; 4] = [
- "IGGY_CONFIG_PATH",
- "IGGY_ROOT_USERNAME",
- "IGGY_ROOT_PASSWORD",
- "IGGY_TEST_VERBOSE",
-];
+pub const NON_CONFIG_ENV_VARS: &[&str] = configs::server::SERVER_PROCESS_ENV_VARS;
/// Resolve config paths to environment variable names.
///
@@ -40,8 +35,8 @@
///
/// # Implicit defaults
///
-/// - `encryption` is shorthand for `system.encryption.key`.
-/// - Setting that key also turns `system.encryption.enabled` on, unless the
+/// - `encryption` is shorthand for `encryption.key`.
+/// - Setting that key also turns `encryption.enabled` on, unless the
/// caller passed it explicitly.
///
/// # Errors
@@ -54,9 +49,9 @@
let mut needs_encryption_enabled = false;
for (path, value) in overrides {
- // Special shorthand: "encryption" maps to "system.encryption.key"
+ // Special shorthand: "encryption" maps to "encryption.key"
let resolved_path = if path == "encryption" {
- "system.encryption.key"
+ "encryption.key"
} else {
path.as_str()
};
@@ -69,10 +64,7 @@
env_vars.insert(m.env_name.to_string(), value.clone());
// Track if encryption key is set (auto-enable encryption)
- if path == "encryption"
- || path == "encryption.key"
- || path == "system.encryption.key"
- {
+ if path == "encryption" || path == "encryption.key" {
needs_encryption_enabled = true;
}
}
@@ -94,7 +86,7 @@
}
// Auto-enable encryption when key is set
- if needs_encryption_enabled && let Some(m) = find_mapping("system.encryption.enabled") {
+ if needs_encryption_enabled && let Some(m) = find_mapping("encryption.enabled") {
env_vars
.entry(m.env_name.to_string())
.or_insert_with(|| "true".to_string());
@@ -249,16 +241,16 @@
fn resolve_valid_path() {
let mut overrides = HashMap::new();
overrides.insert(
- "system.partition.validate_checksum".to_string(),
+ "partition.validate_checksum".to_string(),
"false".to_string(),
);
let result = resolve_config_paths(&overrides);
assert!(result.is_ok());
let env_vars = result.unwrap();
- assert!(env_vars.contains_key("IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM"));
+ assert!(env_vars.contains_key("IGGY_PARTITION_VALIDATE_CHECKSUM"));
assert_eq!(
- env_vars.get("IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM"),
+ env_vars.get("IGGY_PARTITION_VALIDATE_CHECKSUM"),
Some(&"false".to_string())
);
}
@@ -274,14 +266,14 @@
let result = resolve_config_paths(&overrides);
assert!(result.is_ok());
let env_vars = result.unwrap();
- assert!(env_vars.contains_key("IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM"));
+ assert!(env_vars.contains_key("IGGY_PARTITION_VALIDATE_CHECKSUM"));
}
#[test]
fn validate_env_var_names_accepts_live_names_and_passes_through_non_iggy() {
let envs = HashMap::from([
(
- "IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM".to_string(),
+ "IGGY_PARTITION_VALIDATE_CHECKSUM".to_string(),
"false".to_string(),
),
("RUST_LOG".to_string(), "debug".to_string()),
@@ -305,10 +297,10 @@
fn validate_env_var_names_rejects_a_name_no_config_leaf_reads() {
// The exact shape that went silent when these keys moved to per-topic
// options: a name that was valid before and now does nothing.
- let envs = HashMap::from([("IGGY_SYSTEM_SEGMENT_SIZE".to_string(), "1MiB".to_string())]);
+ let envs = HashMap::from([("IGGY_SEGMENT_SIZE".to_string(), "1MiB".to_string())]);
let error = validate_env_var_names(&envs).expect_err("deleted key must be rejected");
assert!(
- error.contains("IGGY_SYSTEM_SEGMENT_SIZE"),
+ error.contains("IGGY_SEGMENT_SIZE"),
"the report must name the offending variable, got: {error}"
);
}
@@ -336,11 +328,11 @@
assert!(result.is_ok());
let env_vars = result.unwrap();
assert_eq!(
- env_vars.get("IGGY_SYSTEM_ENCRYPTION_KEY"),
+ env_vars.get("IGGY_ENCRYPTION_KEY"),
Some(&"/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE=".to_string())
);
assert_eq!(
- env_vars.get("IGGY_SYSTEM_ENCRYPTION_ENABLED"),
+ env_vars.get("IGGY_ENCRYPTION_ENABLED"),
Some(&"true".to_string())
);
}
diff --git a/core/integration/src/harness/disk.rs b/core/integration/src/harness/disk.rs
index 4b72890..50425d9 100644
--- a/core/integration/src/harness/disk.rs
+++ b/core/integration/src/harness/disk.rs
@@ -75,9 +75,14 @@
///
/// Matches the segment file NAME shape, not the `.log` extension alone and not
/// a `streams/` path prefix: the server's own text log sits under the same data
-/// root, so an extension-only match would count tracing output as segment data.
+/// root. The parent must be a partition ID so quarantined copies are excluded.
pub fn is_segment_log(path: &Path) -> bool {
- path.extension().is_some_and(|extension| extension == "log")
+ // Quarantine directories and private WAL links are not live partition data.
+ path.parent()
+ .and_then(Path::file_name)
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.parse::<u32>().is_ok())
+ && path.extension().is_some_and(|extension| extension == "log")
&& path
.file_stem()
.and_then(|stem| stem.to_str())
@@ -177,7 +182,7 @@
/// commit cadence, which differs between primary (one flush per op) and backup
/// (one flush per committed heartbeat range).
fn is_comparable(rel: &str, include_wal: bool) -> bool {
- let is_segment = rel.starts_with("streams/") && rel.ends_with(".log");
+ let is_segment = rel.starts_with("streams/") && is_segment_log(Path::new(rel));
let is_metadata_wal = rel == "metadata/journal.wal";
is_segment || (include_wal && is_metadata_wal)
}
@@ -356,7 +361,7 @@
&& let Ok(rel) = path.strip_prefix(root)
{
let rel = rel.to_string_lossy().replace('\\', "/");
- if rel.starts_with("streams/") && rel.ends_with(".log") {
+ if is_comparable(&rel, false) {
total += fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0);
}
}
diff --git a/core/integration/src/harness/handle/server.rs b/core/integration/src/harness/handle/server.rs
index 1326c6c..c60d2d1 100644
--- a/core/integration/src/harness/handle/server.rs
+++ b/core/integration/src/harness/handle/server.rs
@@ -281,7 +281,7 @@
fn build_envs(&mut self) -> Result<(), TestBinaryError> {
// Pass through IGGY_* env vars from parent process, except those critical for test isolation.
const PROTECTED_PREFIXES: &[&str] = &[
- "IGGY_SYSTEM_PATH",
+ "IGGY_PATH",
"IGGY_TCP_ADDRESS",
"IGGY_HTTP_ADDRESS",
"IGGY_QUIC_ADDRESS",
@@ -309,13 +309,13 @@
Err(_) => "0..4".to_string(),
};
self.envs
- .entry("IGGY_SYSTEM_SHARDING_CPU_ALLOCATION".to_string())
+ .entry("IGGY_SHARDING_CPU_ALLOCATION".to_string())
.or_insert(cpu_allocation);
// On a 4-core CI runner every server computes the same `0..4` range, so
// pinned shards of concurrently running tests pile onto the same cores
// and starve each other. Leave thread placement to the scheduler.
self.envs
- .entry("IGGY_SYSTEM_SHARDING_PIN_CORES".to_string())
+ .entry("IGGY_SHARDING_PIN_CORES".to_string())
.or_insert_with(|| "false".to_string());
self.envs
@@ -326,10 +326,8 @@
.or_insert_with(|| DEFAULT_ROOT_PASSWORD.to_string());
let data_path = self.data_path();
- self.envs.insert(
- "IGGY_SYSTEM_PATH".to_string(),
- data_path.display().to_string(),
- );
+ self.envs
+ .insert("IGGY_PATH".to_string(), data_path.display().to_string());
// Protocol enablement (special handling for defaults)
if !self.config.quic_enabled {
@@ -351,10 +349,10 @@
// Encryption (special handling for key injection)
if let Some(ref enc) = self.config.encryption {
self.envs
- .entry("IGGY_SYSTEM_ENCRYPTION_ENABLED".to_string())
+ .entry("IGGY_ENCRYPTION_ENABLED".to_string())
.or_insert_with(|| "true".to_string());
self.envs
- .entry("IGGY_SYSTEM_ENCRYPTION_KEY".to_string())
+ .entry("IGGY_ENCRYPTION_KEY".to_string())
.or_insert_with(|| enc.key.clone());
}
@@ -951,7 +949,7 @@
})?
};
- command.env("IGGY_SYSTEM_PATH", data_path.display().to_string());
+ command.env("IGGY_PATH", data_path.display().to_string());
// VSR multi-node tests spawn N shards per node * M nodes per test;
// the 4096-entry per-ring default exhausts dev memlock budgets
// (`ulimit -l` is commonly 8 MiB). Shrink unless the caller already
@@ -966,11 +964,7 @@
// ambient value could silently filter out markers the test asserts.
// A caller that explicitly puts `RUST_LOG` in `extra_envs` adds it back
// through `command.envs` below.
- if self
- .config
- .extra_envs
- .contains_key("IGGY_SYSTEM_LOGGING_LEVEL")
- {
+ if self.config.extra_envs.contains_key("IGGY_LOGGING_LEVEL") {
command.env_remove("RUST_LOG");
}
command.envs(&self.envs);
diff --git a/core/integration/src/harness/orchestrator/builder.rs b/core/integration/src/harness/orchestrator/builder.rs
index fbd8556..a9231dc 100644
--- a/core/integration/src/harness/orchestrator/builder.rs
+++ b/core/integration/src/harness/orchestrator/builder.rs
@@ -393,7 +393,7 @@
.server(
TestServerConfig::builder()
.extra_envs(HashMap::from([(
- "IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM".to_string(),
+ "IGGY_PARTITION_VALIDATE_CHECKSUM".to_string(),
"false".to_string(),
)]))
.build(),
@@ -454,7 +454,7 @@
.websocket_enabled(false)
.extra_envs(HashMap::from([
(
- "IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM".to_string(),
+ "IGGY_PARTITION_VALIDATE_CHECKSUM".to_string(),
"false".to_string(),
),
("TEST".to_string(), "value".to_string()),
diff --git a/core/integration/tests/cli/topic/test_topic_create_command.rs b/core/integration/tests/cli/topic/test_topic_create_command.rs
index af8dbb0..de4f8e3 100644
--- a/core/integration/tests/cli/topic/test_topic_create_command.rs
+++ b/core/integration/tests/cli/topic/test_topic_create_command.rs
@@ -287,6 +287,24 @@
{CLAP_INDENT}
[default: server_default]
+ --durability <DURABILITY>
+ Message completion policy: replicated or persisted. Both policies store messages on disk
+
+ Possible values:
+ - replicated: Quorum commit without an additional stable-storage barrier
+ - persisted: Quorum commit backed by recoverable stable-storage copies
+{CLAP_INDENT}
+ [default: replicated]
+
+ --consumer-offset-durability <CONSUMER_OFFSET_DURABILITY>
+ Offset completion policy: replicated or persisted. Independent of message durability
+
+ Possible values:
+ - replicated: Quorum commit without an additional stable-storage barrier
+ - persisted: Quorum commit backed by recoverable stable-storage copies
+{CLAP_INDENT}
+ [default: replicated]
+
--set <KEY=VALUE>
Additional topic option as key=value, repeatable
{CLAP_INDENT}
@@ -322,10 +340,14 @@
[MESSAGE_EXPIRY]... Message expiry time in human-readable format like "unlimited" or "15days 2min 2s" [default: server_default]
Options:
- -t, --topic-id <TOPIC_ID> Topic ID to create
- -m, --max-topic-size <MAX_TOPIC_SIZE> Max topic size in human-readable format like "unlimited" or "15GB" [default: server_default]
- --set <KEY=VALUE> Additional topic option as key=value, repeatable
- -h, --help Print help (see more with '--help')
+ -t, --topic-id <TOPIC_ID> Topic ID to create
+ -m, --max-topic-size <MAX_TOPIC_SIZE> Max topic size in human-readable format like "unlimited" or "15GB" [default: server_default]
+ --durability <DURABILITY> Message completion policy: replicated or persisted. Both policies store messages on disk [default: replicated] [possible values:
+ replicated, persisted]
+ --consumer-offset-durability <CONSUMER_OFFSET_DURABILITY> Offset completion policy: replicated or persisted. Independent of message durability [default: replicated] [possible values:
+ replicated, persisted]
+ --set <KEY=VALUE> Additional topic option as key=value, repeatable
+ -h, --help Print help (see more with '--help')
"#,
),
))
diff --git a/core/integration/tests/cluster/crash_durability.rs b/core/integration/tests/cluster/crash_durability.rs
index 95bb23e..61d58f1 100644
--- a/core/integration/tests/cluster/crash_durability.rs
+++ b/core/integration/tests/cluster/crash_durability.rs
@@ -75,7 +75,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
..TopicCreateOptions::default()
}
} else {
@@ -522,3 +522,340 @@
panic!("eagerly flushed acked data must survive a whole-cluster SIGKILL: {state}")
});
}
+
+/// The kill follows the confirmation without waiting for segment installation.
+/// This exercises the prepare WAL rather than graceful shutdown or page-cache flushes.
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_persisted_topic_when_killed_below_flush_threshold_should_recover_acked_messages(
+ harness: &mut TestHarness,
+) {
+ verify_persisted_restart(harness, Durability::Persisted).await;
+}
+
+#[iggy_harness(cluster_nodes = 1)]
+async fn given_persisted_singleton_when_killed_below_flush_threshold_should_recover_acked_messages(
+ harness: &mut TestHarness,
+) {
+ verify_persisted_restart(harness, Durability::Persisted).await;
+}
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_mixed_durability_when_killed_below_flush_threshold_should_recover_offset_predecessors(
+ harness: &mut TestHarness,
+) {
+ verify_persisted_restart(harness, Durability::Replicated).await;
+}
+
+async fn verify_persisted_restart(harness: &mut TestHarness, durability: Durability) {
+ let client = harness.tcp_root_client().await.unwrap();
+ client.create_stream(STREAM_NAME).await.unwrap();
+ let stream = Identifier::named(STREAM_NAME).unwrap();
+ client
+ .create_topic(
+ &stream,
+ TOPIC_NAME,
+ &TopicCreateOptions {
+ partitions_count: Some(1),
+ durability,
+ consumer_offset_durability: Durability::Persisted,
+ ..TopicCreateOptions::default()
+ },
+ )
+ .await
+ .unwrap();
+ let topic = Identifier::named(TOPIC_NAME).unwrap();
+ let consumer = Consumer::new(Identifier::numeric(CONSUMER_ID).unwrap());
+ let mut acked = Vec::new();
+ for group in 0..3 {
+ acked.extend(produce_acked(&client, &format!("durable-prepare-{group}"), 4).await);
+ client
+ .store_consumer_offset(
+ &consumer,
+ &stream,
+ &topic,
+ Some(PARTITION_ID),
+ acked.last().unwrap().0,
+ )
+ .await
+ .unwrap();
+ }
+ let stored_offset = acked.last().unwrap().0;
+ harness.kill_cluster().unwrap();
+ harness.restart_cluster().await.unwrap();
+ let nodes: Vec<usize> = (0..harness.cluster_size()).collect();
+ let client = wait_until_cluster_serves(harness, &nodes, CONVERGE_TIMEOUT).await;
+ wait_for_acked_readable(&client, &acked, CONVERGE_TIMEOUT)
+ .await
+ .unwrap();
+ let deadline = tokio::time::Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ let stored = client
+ .get_consumer_offset(&consumer, &stream, &topic, Some(PARTITION_ID))
+ .await
+ .unwrap();
+ if stored.is_some_and(|stored| stored.stored_offset == stored_offset) {
+ break;
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "durable offset was not recovered"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+}
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_persisted_topic_when_backup_misses_writes_should_repair_before_durable_ack(
+ harness: &mut TestHarness,
+) {
+ let client = harness.tcp_root_client().await.unwrap();
+ client.create_stream(STREAM_NAME).await.unwrap();
+ client
+ .create_topic(
+ &Identifier::named(STREAM_NAME).unwrap(),
+ TOPIC_NAME,
+ &TopicCreateOptions {
+ partitions_count: Some(1),
+ durability: Durability::Persisted,
+ ..TopicCreateOptions::default()
+ },
+ )
+ .await
+ .unwrap();
+ let mut acked = produce_acked(&client, "before-repair", 4).await;
+ let leader = disk::leader_node_index(harness).await;
+ let backup = (0..3).find(|node| *node != leader).unwrap();
+ harness.kill_node(backup).unwrap();
+ acked.extend(produce_acked(&client, "missed", 8).await);
+ harness.restart_node(backup).unwrap();
+ sleep(Duration::from_secs(6)).await;
+ acked.extend(produce_acked(&client, "after-repair", 4).await);
+ harness.kill_cluster().unwrap();
+ harness.restart_cluster().await.unwrap();
+ let client = wait_until_cluster_serves(harness, &[0, 1, 2], CONVERGE_TIMEOUT).await;
+ wait_for_acked_readable(&client, &acked, CONVERGE_TIMEOUT)
+ .await
+ .unwrap();
+}
+
+#[iggy_harness(cluster_nodes = 3, server(partition.wal_bytes_max = "134225920 B"))]
+async fn given_all_replicas_checkpointed_when_restarted_should_elect_and_extend_the_log(
+ harness: &mut TestHarness,
+) {
+ let client = harness.tcp_root_client().await.unwrap();
+ let stream_details = client.create_stream(STREAM_NAME).await.unwrap();
+ let stream = Identifier::numeric(stream_details.id).unwrap();
+ let topic_details = client
+ .create_topic(
+ &stream,
+ TOPIC_NAME,
+ &TopicCreateOptions {
+ partitions_count: Some(1),
+ durability: Durability::Persisted,
+ ..TopicCreateOptions::default()
+ },
+ )
+ .await
+ .unwrap();
+ let topic = Identifier::numeric(topic_details.id).unwrap();
+ for batch in 1..=2u8 {
+ let payload = bytes::Bytes::from(vec![batch; 1024 * 1024]);
+ let mut messages = (0..33)
+ .map(|_| {
+ IggyMessage::builder()
+ .payload(payload.clone())
+ .build()
+ .unwrap()
+ })
+ .collect::<Vec<_>>();
+ client
+ .send_messages(
+ &stream,
+ &topic,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+ }
+ let deadline = tokio::time::Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ let checkpointed = (0..3).all(|node| {
+ let directory = harness.node(node).data_path().join(format!(
+ "streams/{}/topics/{}/partitions/0",
+ stream_details.id, topic_details.id
+ ));
+ std::fs::read_dir(directory).ok().is_some_and(|entries| {
+ entries.flatten().any(|entry| {
+ entry.file_name().to_string_lossy().starts_with("prepares-")
+ && std::fs::read(entry.path().join("frontier"))
+ .ok()
+ .is_some_and(|bytes| {
+ bytes.len() == 4096
+ && u64::from_le_bytes(bytes[48..56].try_into().unwrap()) == 2
+ && u64::from_le_bytes(bytes[72..80].try_into().unwrap()) == 2
+ })
+ })
+ })
+ });
+ if checkpointed {
+ break;
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "all replicas must checkpoint the same head before restart"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+ harness.kill_cluster().unwrap();
+ harness.restart_cluster().await.unwrap();
+ let client = wait_until_cluster_serves(harness, &[0, 1, 2], CONVERGE_TIMEOUT).await;
+ // The transferred checkpoint body exceeds the former offsets-only
+ // artifact cap, so this also verifies admission of a large prepare.
+ let payload = bytes::Bytes::from(vec![3; 1024 * 1024]);
+ let mut next = (0..33)
+ .map(|_| {
+ IggyMessage::builder()
+ .payload(payload.clone())
+ .build()
+ .unwrap()
+ })
+ .collect::<Vec<_>>();
+ let clients = [
+ client,
+ harness.root_client_for_node(1).await.unwrap(),
+ harness.root_client_for_node(2).await.unwrap(),
+ ];
+ let deadline = tokio::time::Instant::now() + CONVERGE_TIMEOUT;
+ let writer = 'admission: loop {
+ for (index, client) in clients.iter().enumerate() {
+ match client
+ .send_messages(&stream, &topic, &Partitioning::partition_id(0), &mut next)
+ .await
+ {
+ Ok(_) => break 'admission index,
+ Err(IggyError::TransientNotAccepted) => {}
+ Err(error) => panic!("checkpointed cluster did not resume writes: {error}"),
+ }
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "checkpointed partition must elect a primary"
+ );
+ sleep(POLL_INTERVAL).await;
+ };
+ let client = &clients[writer];
+ for (offset, value) in [(0, 1u8), (65, 2u8)] {
+ let polled = client
+ .poll_messages(
+ &stream,
+ &topic,
+ Some(0),
+ &Consumer::default(),
+ &PollingStrategy::offset(offset),
+ 1,
+ false,
+ )
+ .await
+ .unwrap();
+ assert_eq!(polled.messages.len(), 1);
+ assert_eq!(
+ polled.messages[0].payload.as_ref(),
+ vec![value; 1024 * 1024]
+ );
+ }
+ verify_checkpoint_quarantine(harness, stream_details.id, topic_details.id, 2).await;
+ verify_checkpoint_quarantine(harness, stream_details.id, topic_details.id, 1).await;
+ verify_transferred_quorum(harness, &stream, &topic).await;
+}
+
+async fn verify_checkpoint_quarantine(
+ harness: &mut TestHarness,
+ stream_id: u32,
+ topic_id: u32,
+ node: usize,
+) {
+ let directory = harness.node(node).data_path().join(format!(
+ "streams/{stream_id}/topics/{topic_id}/partitions/0"
+ ));
+ harness.kill_node(node).unwrap();
+ // An empty segment starting inside the existing segment makes the chain
+ // structurally invalid even when recovery can use a sparse index.
+ std::fs::write(directory.join("00000000000000000001.log"), []).unwrap();
+ std::fs::write(directory.join("00000000000000000001.index"), []).unwrap();
+ harness.restart_node(node).unwrap();
+ let client = harness.root_client_for_node(node).await.unwrap();
+ let stream = Identifier::numeric(stream_id).unwrap();
+ let topic = Identifier::numeric(topic_id).unwrap();
+ let deadline = tokio::time::Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ let repaired = client
+ .poll_messages(
+ &stream,
+ &topic,
+ Some(0),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ 1,
+ false,
+ )
+ .await
+ .is_ok_and(|polled| {
+ polled
+ .messages
+ .first()
+ .is_some_and(|message| message.payload.as_ref() == vec![1; 1024 * 1024])
+ });
+ let quarantined = std::fs::read_dir(directory.parent().unwrap())
+ .unwrap()
+ .flatten()
+ .any(|entry| entry.file_name().to_string_lossy().starts_with("0.fenced."));
+ if repaired && quarantined && !directory.join("materialization.missing").exists() {
+ break;
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "quarantined checkpoint must be restored by full state transfer"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+}
+
+async fn verify_transferred_quorum(
+ harness: &mut TestHarness,
+ stream: &Identifier,
+ topic: &Identifier,
+) {
+ harness.kill_cluster().unwrap();
+ harness.restart_node(1).unwrap();
+ harness.restart_node(2).unwrap();
+ let _ = wait_until_cluster_serves(harness, &[1, 2], CONVERGE_TIMEOUT).await;
+ let clients = [
+ harness.root_client_for_node(1).await.unwrap(),
+ harness.root_client_for_node(2).await.unwrap(),
+ ];
+ let mut messages = vec![
+ IggyMessage::builder()
+ .payload(bytes::Bytes::from_static(b"after-donor-loss"))
+ .build()
+ .unwrap(),
+ ];
+ let deadline = tokio::time::Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ for client in &clients {
+ match client
+ .send_messages(stream, topic, &Partitioning::partition_id(0), &mut messages)
+ .await
+ {
+ Ok(_) => return,
+ Err(IggyError::TransientNotAccepted) => {}
+ Err(error) => panic!("transferred quorum rejected the next operation: {error}"),
+ }
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "transferred replicas must elect without their donor"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+}
diff --git a/core/integration/tests/cluster/crash_recovery_corruption.rs b/core/integration/tests/cluster/crash_recovery_corruption.rs
index c825c2f..fd60dee 100644
--- a/core/integration/tests/cluster/crash_recovery_corruption.rs
+++ b/core/integration/tests/cluster/crash_recovery_corruption.rs
@@ -17,7 +17,7 @@
//! Recovery from on-disk corruption, staged as offline byte surgery: a node is
//! stopped gracefully, one file under its data dir is mutated, and the node is
-//! started again. The four scenarios split along the durability contract:
+//! started again. The scenarios split along the durability contract:
//!
//! - A torn tail of a partition segment `.log` or `.index` is the shape a
//! crash legitimately leaves behind; recovery must absorb it without the
@@ -25,14 +25,18 @@
//! - Interior damage to the metadata WAL or a superblock slot can only be
//! bit-rot or operator error, never a torn append, so boot must refuse
//! loudly and the node heals by rejoining from a clean slate.
+//! - Losing a persisted body named by the durable WAL quarantines the partition
+//! for peer recovery while preserving the damaged files for inspection.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use iggy::prelude::*;
+use iggy_common::Durability;
use integration::harness::{TestHarness, disk};
use integration::iggy_harness;
+use test_case::test_matrix;
use tokio::time::sleep;
const STREAM_NAME: &str = "corruption-stream";
@@ -72,7 +76,7 @@
/// real surviving prefix behind the one entry it strands past the log end.
const MIN_INDEX_ENTRIES: usize = 4;
/// Infix of the directory the refusal path renames a partition's segment files
-/// into (`partitions::state_transfer::quarantine_segment_files`).
+/// into (`partitions::state_transfer::quarantine_partition_files`).
const FENCED_DIR_MARKER: &str = ".fenced.";
/// Boot log line recovery emits when the log cannot back the last entry of an
/// index (`server::segment_recovery::recover_segment_bounds`): the positive
@@ -80,19 +84,19 @@
/// Distinct from the line the self-contradicting-index check emits, which ends
/// "rebuilding it from the log".
const INDEX_REBUILD_MARKER: &str = "discarding the index and rebuilding it from a byte-0 walk";
+const PARTITION_WAL_REFUSAL_MARKER: &str = "prepare WAL at";
-async fn create_stream_and_topic(client: &IggyClient) {
+async fn create_stream_and_topic(client: &IggyClient, durability: Durability) {
client
.create_stream(STREAM_NAME)
.await
.expect("create stream");
- // Eager flush persists and fsyncs every committed batch on every replica,
- // so the on-disk oracles below observe exactly what was acked.
+ // Eager flush makes committed batches visible to the on-disk oracles.
let options = TopicCreateOptions {
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(true),
+ durability,
..TopicCreateOptions::default()
};
client
@@ -423,7 +427,7 @@
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client).await;
+ create_stream_and_topic(&client, Durability::Persisted).await;
let mut acked = produce_acked(&client, "pre-torn", 30).await;
let pre_payloads: Vec<String> = acked.iter().map(|(_, payload)| payload.clone()).collect();
for node in 0..harness.cluster_size() {
@@ -509,7 +513,7 @@
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client).await;
+ create_stream_and_topic(&client, Durability::Persisted).await;
let mut acked = produce_acked(&client, "pre-torn-index", 30).await;
let pre_payloads: Vec<String> = acked.iter().map(|(_, payload)| payload.clone()).collect();
for node in 0..harness.cluster_size() {
@@ -567,28 +571,18 @@
});
}
-/// A crash can leave a node's segment `.log` shorter than its already durable
-/// `.index` claims: the two files are persisted concurrently, so death between
-/// them strands the entry of the chunk that was in flight even under
-/// `enforce_fsync`. That one entry is the whole window: every earlier entry
-/// belongs to a completed serialized flush whose log fdatasync finished before
-/// the later flush began, so it is the shape the surgery reproduces. The index is a rebuildable local
-/// artifact and the log is the authority, so recovery must discard the index,
-/// rebuild it from a byte-0 walk of the log, and keep serving the batches the
-/// walk proves - not refuse the chain, which fences every surviving byte aside
-/// on a cluster and tombstones the partition outright on a single replica. The
-/// walk starts at byte 0 rather than at the highest entry the log still backs
-/// because an anchor above the damage would leave everything below it unread.
-/// The spec pins the whole outcome: the node boots without a refusal, nothing
-/// is fenced, its index no longer points past its log, catch-up refills the
-/// truncated tail, every acked offset reads back, and the replicas end
-/// byte-identical.
+/// Replicated storage rebuilds a stale index from the surviving log and refills
+/// the tail from peers. Persisted storage syncs bodies before publishing WAL
+/// references, so deleting that tail must quarantine the damaged partition.
+/// Peer recovery must restore every acknowledged message without wiping the node.
#[iggy_harness(cluster_nodes = 3)]
-async fn given_a_durable_index_ahead_of_a_truncated_log_when_a_node_recovers_should_rebuild_the_index_and_serve_the_surviving_prefix(
+#[test_matrix([Durability::Replicated, Durability::Persisted])]
+async fn given_an_index_ahead_of_a_truncated_log_when_a_node_recovers_should_preserve_acked_messages(
harness: &mut TestHarness,
+ durability: Durability,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client).await;
+ create_stream_and_topic(&client, durability).await;
let acked = produce_acked(&client, "index-ahead", INDEX_AHEAD_BATCHES).await;
let payloads: Vec<String> = acked.iter().map(|(_, payload)| payload.clone()).collect();
for node in 0..harness.cluster_size() {
@@ -626,13 +620,8 @@
let log_size = fs::metadata(&log_path)
.map(|meta| meta.len())
.unwrap_or_else(|error| panic!("stat {}: {error}", log_path.display()));
- // Cutting at the LAST entry's position lands the log end exactly on a
- // batch boundary, keeps whole batches behind it, and strands exactly one
- // entry past the end of the file - the only depth a crash can produce
- // under `enforce_fsync`, where each serialized flush fdatasyncs the whole
- // log before the next chunk's entry can exist. A deeper cut would
- // fabricate previously durable data loss, which recovery refuses by
- // design.
+ // Keep whole batches and strand the last index entry past the log end.
+ // Persisted WAL references lose the same bytes through their hard link.
let cut_at = positions[positions.len() - 1];
assert!(
cut_at > 0 && cut_at < log_size,
@@ -650,80 +639,112 @@
payloads.len()
);
- harness.restart_node(backup).unwrap_or_else(|error| {
- panic!(
- "an index ahead of its log is what a crash between the two writes leaves; \
- boot must rebuild the index instead of failing: {error}"
- )
- });
-
- // Read the index BEFORE the log, both right after boot: catch-up grows
- // the two files together, so a log still at the cut proves the index was
- // read before any append landed, and the rebuild is then the only shape it
- // may have. Once the log has grown the refilled tail re-mints entries over
- // the same bytes, and nothing on disk tells the two apart; the boot log
- // marker below is the evidence that survives that.
- //
- // The rebuild's stride is its own, so the entry COUNT is not the spec.
- // What is: the index describes only bytes the walk proved, which is the
- // property the stranded entry violated.
- let recovered_positions = index_positions(&index_path);
- let recovered_index_len = fs::metadata(&index_path)
- .map(|meta| meta.len())
- .unwrap_or_else(|error| panic!("stat {}: {error}", index_path.display()));
- let recovered_log_len = fs::metadata(&log_path)
- .map(|meta| meta.len())
- .unwrap_or_else(|error| panic!("stat {}: {error}", log_path.display()));
- if recovered_log_len == cut_at {
+ if durability == Durability::Persisted {
+ harness
+ .restart_node(backup)
+ .expect("boot must quarantine the damaged partition for peer recovery");
+ if stderr_is_captured() {
+ assert!(
+ harness
+ .node(backup)
+ .stdout_contains(PARTITION_WAL_REFUSAL_MARKER),
+ "boot must diagnose the damaged partition WAL"
+ );
+ }
+ let fenced = fenced_segment_paths(&backup_data);
+ let refused_log = fenced
+ .iter()
+ .find(|path| path.file_name() == log_path.file_name())
+ .expect("the damaged public segment must be quarantined");
assert_eq!(
- recovered_index_len as usize % INDEX_ENTRY_SIZE,
- 0,
- "the recovered index must hold whole entries; it is {recovered_index_len} bytes"
+ fs::read(refused_log).expect("read the quarantined segment"),
+ truncated,
+ "quarantine must preserve the damaged segment for diagnosis"
);
- assert!(
- !recovered_positions.is_empty(),
- "the {cut_at}-byte log holds whole batches, so the rebuilt index must not be empty"
- );
- assert!(
- recovered_positions
- .iter()
- .all(|position| *position < cut_at),
- "every rebuilt entry must open inside the {cut_at}-byte log, got \
- {recovered_positions:?}"
- );
- }
+ let survivors: Vec<usize> = (0..harness.cluster_size())
+ .filter(|node| *node != backup)
+ .collect();
+ let client = wait_until_cluster_serves(harness, &survivors, CONVERGE_TIMEOUT).await;
+ wait_for_acked_readable(&client, &acked, CONVERGE_TIMEOUT)
+ .await
+ .unwrap_or_else(|state| {
+ panic!("the surviving quorum must preserve every ack: {state}")
+ });
+ } else {
+ harness.restart_node(backup).unwrap_or_else(|error| {
+ panic!(
+ "an index ahead of its replicated log must be rebuilt instead of failing: {error}"
+ )
+ });
- let fenced = fenced_segment_paths(&backup_data);
- assert!(
- fenced.is_empty(),
- "recovery must rebuild the index from the log, keeping the {} \
+ // Read the index BEFORE the log, both right after boot: catch-up grows
+ // the two files together, so a log still at the cut proves the index was
+ // read before any append landed, and the rebuild is then the only shape it
+ // may have. Once the log has grown the refilled tail re-mints entries over
+ // the same bytes, and nothing on disk tells the two apart; the boot log
+ // marker below is the evidence that survives that.
+ //
+ // The rebuild's stride is its own, so the entry COUNT is not the spec.
+ // What is: the index describes only bytes the walk proved, which is the
+ // property the stranded entry violated.
+ let recovered_positions = index_positions(&index_path);
+ let recovered_index_len = fs::metadata(&index_path)
+ .map(|meta| meta.len())
+ .unwrap_or_else(|error| panic!("stat {}: {error}", index_path.display()));
+ let recovered_log_len = fs::metadata(&log_path)
+ .map(|meta| meta.len())
+ .unwrap_or_else(|error| panic!("stat {}: {error}", log_path.display()));
+ if recovered_log_len == cut_at {
+ assert_eq!(
+ recovered_index_len as usize % INDEX_ENTRY_SIZE,
+ 0,
+ "the recovered index must hold whole entries; it is {recovered_index_len} bytes"
+ );
+ assert!(
+ !recovered_positions.is_empty(),
+ "the {cut_at}-byte log holds whole batches, so the rebuilt index must not be empty"
+ );
+ assert!(
+ recovered_positions
+ .iter()
+ .all(|position| *position < cut_at),
+ "every rebuilt entry must open inside the {cut_at}-byte log, got \
+ {recovered_positions:?}"
+ );
+ }
+
+ let fenced = fenced_segment_paths(&backup_data);
+ assert!(
+ fenced.is_empty(),
+ "recovery must rebuild the index from the log, keeping the {} \
surviving batches in service; instead the chain was refused and fenced aside: {fenced:?}",
- surviving.len()
- );
- // `fenced_segment_paths` walks past unreadable directories, so an empty
- // result alone could be vacuous: the files must still be where boot found
- // them.
- assert!(
- log_path.exists() && index_path.exists(),
- "the segment files must stay in place after recovery; missing under {}",
- backup_data.display()
- );
- // `restart_node` truncates the node's stdout log, so a marker found here
- // was logged by the boot just performed. Under `IGGY_TEST_VERBOSE` the
- // child's output is inherited and no file exists to read, which would make
- // either check vacuous.
- if stderr_is_captured() {
- assert!(
- !harness
- .node(backup)
- .stdout_contains("refusing the recovered segment chain"),
- "boot must absorb an index that outruns its log, not refuse the chain"
+ surviving.len()
);
+ // `fenced_segment_paths` walks past unreadable directories, so an empty
+ // result alone could be vacuous: the files must still be where boot found
+ // them.
assert!(
- harness.node(backup).stdout_contains(INDEX_REBUILD_MARKER),
- "boot must log the byte-0 rebuild ({INDEX_REBUILD_MARKER:?}); recovery took \
+ log_path.exists() && index_path.exists(),
+ "the segment files must stay in place after recovery; missing under {}",
+ backup_data.display()
+ );
+ // `restart_node` truncates the node's stdout log, so a marker found here
+ // was logged by the boot just performed. Under `IGGY_TEST_VERBOSE` the
+ // child's output is inherited and no file exists to read, which would make
+ // either check vacuous.
+ if stderr_is_captured() {
+ assert!(
+ !harness
+ .node(backup)
+ .stdout_contains("refusing the recovered segment chain"),
+ "boot must absorb an index that outruns its log, not refuse the chain"
+ );
+ assert!(
+ harness.node(backup).stdout_contains(INDEX_REBUILD_MARKER),
+ "boot must log the byte-0 rebuild ({INDEX_REBUILD_MARKER:?}); recovery took \
another path"
- );
+ );
+ }
}
wait_until_node_holds_payloads(
@@ -731,7 +752,7 @@
backup,
&payloads,
CONVERGE_TIMEOUT,
- "catch-up refilling the truncated tail",
+ "recovery of the truncated tail",
)
.await;
@@ -740,7 +761,7 @@
wait_for_acked_readable(&client, &acked, CONVERGE_TIMEOUT)
.await
.unwrap_or_else(|state| {
- panic!("every acked offset must poll back after the rebuilt-index recovery: {state}")
+ panic!("every acked offset must poll back after the truncated-log recovery: {state}")
});
let data_paths: Vec<PathBuf> = harness
@@ -765,7 +786,7 @@
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client).await;
+ create_stream_and_topic(&client, Durability::Persisted).await;
let mut acked = produce_acked(&client, "pre-fault", 20).await;
// Extra committed metadata ops so the flip at one quarter of the WAL
// provably precedes many complete entries.
@@ -842,7 +863,7 @@
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client).await;
+ create_stream_and_topic(&client, Durability::Persisted).await;
let mut acked = produce_acked(&client, "pre-fault", 20).await;
let pre_payloads: Vec<String> = acked.iter().map(|(_, payload)| payload.clone()).collect();
for node in 0..harness.cluster_size() {
diff --git a/core/integration/tests/cluster/failover_client_continuity.rs b/core/integration/tests/cluster/failover_client_continuity.rs
index 9eeed6c..a52c011 100644
--- a/core/integration/tests/cluster/failover_client_continuity.rs
+++ b/core/integration/tests/cluster/failover_client_continuity.rs
@@ -79,7 +79,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
..TopicCreateOptions::default()
};
setup_client
diff --git a/core/integration/tests/cluster/fast_primary_rejoin.rs b/core/integration/tests/cluster/fast_primary_rejoin.rs
index 435c4ce..6856654 100644
--- a/core/integration/tests/cluster/fast_primary_rejoin.rs
+++ b/core/integration/tests/cluster/fast_primary_rejoin.rs
@@ -79,7 +79,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
..TopicCreateOptions::default()
};
setup_client
diff --git a/core/integration/tests/cluster/metadata_checkpoint_restart.rs b/core/integration/tests/cluster/metadata_checkpoint_restart.rs
index 4da0838..efcf6ff 100644
--- a/core/integration/tests/cluster/metadata_checkpoint_restart.rs
+++ b/core/integration/tests/cluster/metadata_checkpoint_restart.rs
@@ -343,7 +343,7 @@
// bulk creation is fast and the WAL is fully committed with no uncommitted suffix to
// reconcile, exercising checkpoint and snapshot-fold recovery in isolation without an
// election in the mix.
-#[iggy_harness(cluster_nodes = 1, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 1, server(sharding.cpu_allocation = "0..1"))]
async fn given_checkpointed_metadata_when_solo_replica_restarts_should_recover_from_snapshot_and_wal(
harness: &mut TestHarness,
) {
diff --git a/core/integration/tests/cluster/multi_shard_partition_convergence.rs b/core/integration/tests/cluster/multi_shard_partition_convergence.rs
index 88a782e..ceb72bc 100644
--- a/core/integration/tests/cluster/multi_shard_partition_convergence.rs
+++ b/core/integration/tests/cluster/multi_shard_partition_convergence.rs
@@ -139,7 +139,7 @@
/// Topics are created in a batch first, so several materialisations are in
/// flight at once when the produces start.
-#[iggy_harness(cluster_nodes = 1, server(system.sharding.cpu_allocation = "2"))]
+#[iggy_harness(cluster_nodes = 1, server(sharding.cpu_allocation = "2"))]
async fn given_two_shards_when_producing_right_after_create_topic_should_round_trip(
harness: &TestHarness,
) {
@@ -183,7 +183,7 @@
/// fence needs a produce concurrent with the delete from a second connection,
/// which is a different test. This one catches the steady-state failures: a
/// rebuild that wedges, or stale segments served under the recycled identity.
-#[iggy_harness(cluster_nodes = 1, server(system.sharding.cpu_allocation = "2"))]
+#[iggy_harness(cluster_nodes = 1, server(sharding.cpu_allocation = "2"))]
async fn given_two_shards_when_recreating_a_topic_should_serve_only_the_new_incarnation(
harness: &TestHarness,
) {
diff --git a/core/integration/tests/cluster/parked_frame_redispatch.rs b/core/integration/tests/cluster/parked_frame_redispatch.rs
index 23bc22b..9a47fa4 100644
--- a/core/integration/tests/cluster/parked_frame_redispatch.rs
+++ b/core/integration/tests/cluster/parked_frame_redispatch.rs
@@ -40,7 +40,7 @@
//! Three things are asserted, and they fail separately:
//!
//! - The path was entered on a backup. `redispatch_parked_frames` logs at
-//! `debug`, hence the `system.logging.level` override; the marker on a node
+//! `debug`, hence the `logging.level` override; the marker on a node
//! that is not the leader is proof, because a fresh partition group seeds its
//! view from the metadata plane, so every partition primary here is the
//! metadata leader and no client request lands anywhere else.
@@ -113,7 +113,7 @@
format!("parked-redispatch-topic-{index}")
}
-#[iggy_harness(cluster_nodes = 3, server(system.logging.level = "info,shard=debug"))]
+#[iggy_harness(cluster_nodes = 3, server(logging.level = "info,shard=debug"))]
async fn given_a_produce_burst_right_after_create_topic_when_backups_park_the_prepares_should_re_dispatch_them_in_order(
harness: &mut TestHarness,
) {
@@ -176,7 +176,7 @@
disk::assert_replica_data_identical(&data_paths, false);
}
-/// `messages_required_to_save` + `enforce_fsync` persist every committed batch
+/// `messages_required_to_save` + `durability=persisted` persist every committed batch
/// on every replica, which is what makes the on-disk assertions mean anything
/// on a run this small; the default thresholds would ack from RAM alone.
async fn create_topic(client: &IggyClient, stream: &Identifier, name: &str) {
@@ -188,7 +188,7 @@
partitions_count: Some(PARTITIONS),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
..TopicCreateOptions::default()
},
)
diff --git a/core/integration/tests/cluster/partition_dedup.rs b/core/integration/tests/cluster/partition_dedup.rs
index 892211b..dc2c66c 100644
--- a/core/integration/tests/cluster/partition_dedup.rs
+++ b/core/integration/tests/cluster/partition_dedup.rs
@@ -69,7 +69,7 @@
const COMMIT_BUDGET: Duration = Duration::from_secs(20);
const RETRY_PAUSE: Duration = Duration::from_millis(100);
-#[iggy_harness(server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(server(sharding.cpu_allocation = "0..1"))]
async fn given_committed_send_when_replayed_should_absorb_without_a_second_copy(
harness: &mut TestHarness,
) {
@@ -102,7 +102,7 @@
);
}
-#[iggy_harness(server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(server(sharding.cpu_allocation = "0..1"))]
async fn given_committed_send_when_next_request_id_arrives_should_admit_it(
harness: &mut TestHarness,
) {
@@ -128,7 +128,7 @@
assert_eq!(polled, 3, "each distinct request id must append once");
}
-#[iggy_harness(server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(server(sharding.cpu_allocation = "0..1"))]
async fn given_gapped_request_id_when_sent_should_commit(harness: &mut TestHarness) {
// One client counter feeds every group it writes to, so a slice only ever
// sees a subset of the ids minted. Gaps must be legal, not a wedge.
@@ -152,7 +152,7 @@
assert_eq!(polled, 3, "a gapped id is new, not a duplicate");
}
-#[iggy_harness(server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(server(sharding.cpu_allocation = "0..1"))]
async fn given_committed_consumer_offset_when_replayed_should_absorb(harness: &mut TestHarness) {
// Dedup covers every replicated partition write, not just produces. A
// replayed offset store must answer success rather than committing twice.
@@ -211,7 +211,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
partition.prepare_queue_depth = "4"
)
)]
@@ -315,7 +315,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
partition.evicted_ring_capacity = "64"
)
)]
diff --git a/core/integration/tests/cluster/partition_state_transfer.rs b/core/integration/tests/cluster/partition_state_transfer.rs
index daef6ed..4e43291 100644
--- a/core/integration/tests/cluster/partition_state_transfer.rs
+++ b/core/integration/tests/cluster/partition_state_transfer.rs
@@ -88,7 +88,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
partition.evicted_ring_capacity = "64"
)
)]
@@ -192,7 +192,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
partition.evicted_ring_capacity = "64"
)
)]
@@ -243,7 +243,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
partition.evicted_ring_capacity = "64"
)
)]
diff --git a/core/integration/tests/cluster/register_forwarding.rs b/core/integration/tests/cluster/register_forwarding.rs
index f85d74d..df206c8 100644
--- a/core/integration/tests/cluster/register_forwarding.rs
+++ b/core/integration/tests/cluster/register_forwarding.rs
@@ -129,7 +129,7 @@
}
}
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_backup_when_a_client_signs_in_should_bind_the_session_there(
harness: &TestHarness,
) {
@@ -149,7 +149,7 @@
.expect("the backup must serve an authenticated read on the session it bound");
}
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_backup_bound_session_when_the_client_logs_out_should_remove_it_cluster_wide(
harness: &TestHarness,
) {
@@ -174,7 +174,7 @@
);
}
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_backup_when_a_pat_login_arrives_should_bind_the_session_there(
harness: &TestHarness,
) {
@@ -219,7 +219,7 @@
.expect("the backup must serve an authenticated read on the session it bound");
}
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_backup_when_credentials_are_wrong_should_refuse_terminally(harness: &TestHarness) {
let address = backup_address(harness).await;
let client = connect_without_login(address).await;
@@ -243,7 +243,7 @@
/// Deliberately kills a FOLLOWER rather than the primary: killing the
/// primary tests re-election, not this feature. The sibling test below
/// covers the primary-kill path.
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_degraded_cluster_when_a_client_signs_in_at_a_backup_should_succeed(
harness: &mut TestHarness,
) {
@@ -280,7 +280,7 @@
/// starve on roughly half the runs before the view-start pipeline fix
/// (a register admitted during the superblock persist panicked the pump);
/// the deterministic interleaving is pinned by the simulator gate.
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_killed_primary_when_a_client_signs_in_at_a_survivor_should_succeed(
harness: &mut TestHarness,
) {
@@ -310,7 +310,7 @@
assert_eq!(identity.user_id, 0, "root user should have id 0");
}
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_backup_when_auto_login_dials_it_should_settle_on_the_leader(
harness: &TestHarness,
) {
diff --git a/core/integration/tests/cluster/staggered_bootstrap.rs b/core/integration/tests/cluster/staggered_bootstrap.rs
index a997990..2103a37 100644
--- a/core/integration/tests/cluster/staggered_bootstrap.rs
+++ b/core/integration/tests/cluster/staggered_bootstrap.rs
@@ -45,7 +45,7 @@
use std::time::Duration;
use iggy::prelude::*;
-use integration::harness::disk::leader_node_index_via;
+use integration::harness::disk::{leader_node_index_via, read_metadata_superblock_state};
use integration::iggy_harness;
use tokio::time::{Instant, sleep};
@@ -63,24 +63,7 @@
const SEND_BUDGET: Duration = Duration::from_secs(20);
/// How long the late replica gets to show it has caught up.
const CONVERGE_BUDGET: Duration = Duration::from_secs(30);
-const MARKER_POLL: Duration = Duration::from_millis(250);
-
-/// The late replica joining the view the others elected without it. Its own
-/// recorded view is 0, which names ITSELF primary, so this line is where it
-/// gives that up.
-const VIEW_ADOPTED_MARKER: &str = "adopting view from StartView";
-
-/// Markers that each independently prove the late replica pulled committed
-/// state it did not have. Which one fires depends on whether the gap sits
-/// above or below the serving peers' retained journal floor: repair refills
-/// from the peers' journals, state transfer is what a gap below the retained
-/// floor converts into. A cluster this small usually stays above the floor and
-/// repairs, but the size of the founding quorum's log is not something this
-/// test fixes, so either counts.
-const CAUGHT_UP_MARKERS: [&str; 2] = [
- "metadata journal repair walked",
- "metadata state transfer installed",
-];
+const CONVERGENCE_POLL: Duration = Duration::from_millis(250);
fn message(payload: &str) -> IggyMessage {
IggyMessage::from_str(payload).expect("build message")
@@ -119,12 +102,12 @@
.root_client_for_node(leader)
.await
.expect("root client on the metadata leader");
- setup
+ let created_stream = setup
.create_stream(STREAM_NAME)
.await
.expect("create stream");
let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
- setup
+ let created_topic = setup
.create_topic(
&stream_id,
TOPIC_NAME,
@@ -155,26 +138,28 @@
be accepted, got {accepted:?}"
);
- // The late replica missed every op committed before it arrived. Read off
- // its own log rather than through a client: the SDK redirects to the
- // leader on connect, so a client dialing node 0 reports the leader's state,
- // not node 0's.
+ // Local materialization requires applying CreateTopic and every preceding
+ // metadata op. Normal commit processing can finish catch-up without a
+ // repair-completion log, and an SDK read could redirect to another node.
+ let late_data_path = harness.node(0).data_path();
+ let partition_path = late_data_path.join(format!(
+ "streams/{}/topics/{}/partitions/{PARTITION_ID}",
+ created_stream.id, created_topic.id
+ ));
let deadline = Instant::now() + CONVERGE_BUDGET;
loop {
- let late = harness.node(0);
- let adopted_view = late.stdout_contains(VIEW_ADOPTED_MARKER);
- let caught_up = CAUGHT_UP_MARKERS
- .iter()
- .any(|marker| late.stdout_contains(marker));
+ let adopted_view =
+ read_metadata_superblock_state(&late_data_path).is_some_and(|state| state.view > 0);
+ let caught_up = partition_path.is_dir();
if adopted_view && caught_up {
break;
}
assert!(
Instant::now() < deadline,
"the late replica 0 never converged within {CONVERGE_BUDGET:?} \
- (adopted the live view: {adopted_view}, caught up on committed ops: {caught_up}); \
+ (adopted the live view: {adopted_view}, materialized replicated topic: {caught_up}); \
the harness's all-nodes mesh gate is what normally keeps a node out of this position"
);
- sleep(MARKER_POLL).await;
+ sleep(CONVERGENCE_POLL).await;
}
}
diff --git a/core/integration/tests/config_provider/mod.rs b/core/integration/tests/config_provider/mod.rs
index db7472d..a2a830a 100644
--- a/core/integration/tests/config_provider/mod.rs
+++ b/core/integration/tests/config_provider/mod.rs
@@ -37,7 +37,7 @@
env::set_var("IGGY_HTTP_ENABLED", expected_http.to_string());
env::set_var("IGGY_TCP_ENABLED", expected_tcp.to_string());
env::set_var(
- "IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM",
+ "IGGY_PARTITION_VALIDATE_CHECKSUM",
expected_validate_checksum.to_string(),
);
}
@@ -53,14 +53,14 @@
assert_eq!(config.http.enabled, expected_http);
assert_eq!(config.tcp.enabled, expected_tcp);
assert_eq!(
- config.system.partition.validate_checksum,
+ config.partition.validate_checksum,
expected_validate_checksum
);
unsafe {
env::remove_var("IGGY_HTTP_ENABLED");
env::remove_var("IGGY_TCP_ENABLED");
- env::remove_var("IGGY_SYSTEM_PARTITION_VALIDATE_CHECKSUM");
+ env::remove_var("IGGY_PARTITION_VALIDATE_CHECKSUM");
}
}
diff --git a/core/integration/tests/data_integrity/storage_compat.rs b/core/integration/tests/data_integrity/storage_compat.rs
index ea09f47..0191345 100644
--- a/core/integration/tests/data_integrity/storage_compat.rs
+++ b/core/integration/tests/data_integrity/storage_compat.rs
@@ -57,30 +57,19 @@
//! unseals the last segment, so a chain that ends on a rotation boundary
//! would only ever hand that path an empty file.
//!
-//! # The configuration is held constant across the swap
+//! # Each binary reads its own configuration schema
//!
-//! Both boots read the BASELINE's `core/server/config.toml`, extracted next
-//! to the baseline binary by `scripts/ci/storage-compat.sh`. Neither side may
-//! fall back to the server's relative default path: figment resolves that by
-//! walking up from the test process's directory, so the BASELINE parses THIS
-//! branch's file, and a key added under a `deny_unknown_fields` table (every
-//! `[cluster]` and `[node]` one) fails its extraction with
-//! `Config(CannotLoadConfiguration)`. That reads as a compatibility break and
-//! is not one.
+//! The baseline uses its extracted config and a test-only launcher that maps
+//! current harness environment names to the baseline's system-prefixed names.
+//! On replacement, the fixture copies supported values into the current schema
+//! and switches IGGY_CONFIG_PATH before restarting. Removed settings stay out
+//! of the replacement config. This isolates storage compatibility from the
+//! deliberately breaking public configuration rename.
//!
-//! One file across the swap leaves the binary as the only variable, and it
-//! pins the two rules the config plane already carries: a field this branch
-//! adds needs its `#[serde(default)]`, because the build under test boots on
-//! a file written before the field existed, and a key this branch takes away
-//! needs its `RelocatedKey` entry. Deleting a key from a
-//! `deny_unknown_fields` table, or relocating one, makes the SECOND boot
-//! refuse the file; strip that key from the extracted copy when it happens.
-//!
-//! The overrides below reach both binaries as `IGGY_*` variables, and
-//! `resolve_config_paths` checks them against the catalog of the build under
-//! test only. [`assert_overrides_known_to_the_baseline`] covers the other
-//! side, where an unknown `IGGY_*` name trips a `debug_assert` and takes the
-//! debug binary down at boot.
+//! Topic creation likewise uses the baseline options block. The new durability
+//! keys are omitted. Retired durable settings are deliberately not seeded. After the
+//! swap, retired metadata options are discarded and both new policies derive
+//! their independent replicated defaults. Production SDKs gain no fallback.
//!
//! `IGGY_TEST_VERBOSE` makes the harness inherit the server's stdout instead
//! of capturing it, which would make the tombstone check vacuous. The
@@ -89,12 +78,20 @@
use bytes::Bytes;
use iggy::prelude::*;
+use iggy_binary_protocol::codec::WireEncode;
+use iggy_binary_protocol::primitives::identifier::WireName;
+use iggy_binary_protocol::requests::topics::create_topic::CreateTopicRequest;
+use iggy_common::OptionsProvenance;
+use iggy_common::wire_conversions::{
+ identifier_to_wire, resource_options_from_wire, resource_options_to_wire,
+};
use integration::harness::{
TestHarness, TestServerConfig, USER_PASSWORD, disk, resolve_config_paths,
};
use serial_test::parallel;
use std::collections::{BTreeMap, HashMap};
use std::fs;
+use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, Instant};
@@ -108,8 +105,8 @@
/// `debug_assert` and kills the debug build this test runs against.
const BASELINE_SERVER_ENV: &str = "COMPAT_BASELINE_SERVER";
-/// Absolute path to the baseline's `core/server/config.toml`, which both
-/// boots read. Same naming rule as [`BASELINE_SERVER_ENV`].
+/// Absolute path to the baseline's `core/server/config.toml`.
+/// Same naming rule as [`BASELINE_SERVER_ENV`].
const BASELINE_CONFIG_ENV: &str = "COMPAT_BASELINE_CONFIG";
/// Selects the server's config file, ahead of the relative default path the
@@ -285,6 +282,23 @@
async fn should_read_back_a_data_directory_written_by_the_baseline_server() {
let baseline = baseline_server_binary();
let baseline_config = baseline_server_config();
+ let fixture = tempfile::tempdir().unwrap();
+ let baseline_document: toml::Value =
+ toml::from_str(&fs::read_to_string(&baseline_config).unwrap()).unwrap();
+ let launcher = baseline_launcher(fixture.path(), &baseline, &baseline_document);
+ let replacement_config = fixture.path().join("replacement.toml");
+ let current_document: toml::Value =
+ toml::from_str(include_str!("../../../server/config.toml")).unwrap();
+ fs::write(
+ &replacement_config,
+ toml::to_string(&replacement_configuration(
+ &baseline_document,
+ current_document,
+ ))
+ .unwrap(),
+ )
+ .unwrap();
+
let overrides = HashMap::from([(
"metadata.journal_slots".to_string(),
JOURNAL_SLOTS.to_string(),
@@ -292,15 +306,14 @@
assert_overrides_known_to_the_baseline(&overrides, &baseline_config);
let mut envs = resolve_config_paths(&overrides)
.expect("metadata.journal_slots resolves against the live config catalog");
- // `extra_envs` is re-applied on every `start()`, so the swapped-in binary
- // reads this same file.
+ // The baseline reads its own schema until the explicit replacement below.
envs.insert(CONFIG_PATH_ENV.to_string(), baseline_config.clone());
let mut harness = TestHarness::builder()
.cluster_nodes(1)
.server(
TestServerConfig::builder()
- .executable_path(baseline.clone())
+ .executable_path(launcher.to_string_lossy().into_owned())
.extra_envs(envs)
.build(),
)
@@ -322,10 +335,10 @@
// partition directory looks empty until shutdown.
let data_stream_details = client.create_stream(DATA_STREAM).await.unwrap();
let data_stream = Identifier::numeric(data_stream_details.id).unwrap();
- let data_topic_details = client
- .create_topic(&data_stream, DATA_TOPIC, &data_topic_options())
- .await
- .unwrap();
+ let data_topic_details =
+ create_baseline_topic(&client, &data_stream, DATA_TOPIC, &data_topic_options())
+ .await
+ .unwrap();
let data_topic = Identifier::numeric(data_topic_details.id).unwrap();
let partition = partition_dir(
&data_path,
@@ -468,10 +481,10 @@
// segment chain, so it must not touch the one above.
let purge_stream_details = client.create_stream(PURGE_STREAM).await.unwrap();
let purge_stream = Identifier::numeric(purge_stream_details.id).unwrap();
- let purge_topic_details = client
- .create_topic(&purge_stream, PURGE_TOPIC, &purge_topic_options())
- .await
- .unwrap();
+ let purge_topic_details =
+ create_baseline_topic(&client, &purge_stream, PURGE_TOPIC, &purge_topic_options())
+ .await
+ .unwrap();
let purge_topic = Identifier::numeric(purge_topic_details.id).unwrap();
let mut purged_batch: Vec<IggyMessage> = (0..PURGED_MESSAGES)
.map(|index| seeded_message(index, Bytes::from(format!("compat-purged-{index}"))))
@@ -573,10 +586,14 @@
// 8. One more of each rich type past the checkpoint, so the WAL encodings
// are exercised as well: a topic with every option key, a user with
// permissions, a token, a group whose membership churned, and streams.
- let tail_topic_details = client
- .create_topic(&data_stream, WAL_TAIL_TOPIC, &wal_tail_topic_options())
- .await
- .unwrap();
+ let tail_topic_details = create_baseline_topic(
+ &client,
+ &data_stream,
+ WAL_TAIL_TOPIC,
+ &wal_tail_topic_options(),
+ )
+ .await
+ .unwrap();
let tail_permissions = seeded_permissions(data_stream_details.id, tail_topic_details.id);
client
.create_user(
@@ -710,6 +727,10 @@
// The swap: `None` selects the cargo-built binary of the crate under test.
harness.server_mut().set_executable_path(None);
+ harness.server_mut().add_env(
+ CONFIG_PATH_ENV,
+ replacement_config.to_string_lossy().into_owned(),
+ );
harness.restart_server().await.unwrap_or_else(|error| {
panic!(
"the server under test did not restart on the data directory the baseline wrote. \
@@ -999,6 +1020,79 @@
path.split('.').try_fold(doc, |node, key| node.get(key))
}
+async fn create_baseline_topic(
+ client: &IggyClient,
+ stream: &Identifier,
+ name: &str,
+ options: &TopicCreateOptions,
+) -> Result<TopicDetails, IggyError> {
+ let legacy = resource_options_from_wire(&options.to_explicit_wire(|_| false)?, true)?;
+ let request = CreateTopicRequest {
+ stream_id: identifier_to_wire(stream)?,
+ partitions_count: options.partitions_count.unwrap_or(1),
+ name: WireName::new(name).unwrap(),
+ options: resource_options_to_wire(&legacy, OptionsProvenance::All)?,
+ };
+ client
+ .send_binary_request(
+ iggy_binary_protocol::codes::CREATE_TOPIC_CODE,
+ request.to_bytes(),
+ )
+ .await?;
+ Ok(client
+ .get_topic(stream, &Identifier::named(name)?)
+ .await?
+ .expect("baseline-created topic"))
+}
+
+fn replacement_configuration(baseline: &toml::Value, mut replacement: toml::Value) -> toml::Value {
+ fn overlay(node: &mut toml::Value, path: &str, baseline: &toml::Value) {
+ if let toml::Value::Table(table) = node {
+ for (key, value) in table {
+ let path = if path.is_empty() {
+ key.clone()
+ } else {
+ format!("{path}.{key}")
+ };
+ overlay(value, &path, baseline);
+ }
+ } else if let Some(value) =
+ config_key(baseline, path).or_else(|| config_key(baseline, &format!("system.{path}")))
+ {
+ *node = value.clone();
+ }
+ }
+ overlay(&mut replacement, "", baseline);
+ replacement
+}
+
+fn baseline_launcher(directory: &Path, binary: &str, baseline: &toml::Value) -> PathBuf {
+ fn mappings(value: &toml::Value, path: &str, output: &mut String) {
+ if let toml::Value::Table(table) = value {
+ for (key, value) in table {
+ mappings(value, &format!("{path}_{key}"), output);
+ }
+ } else {
+ let suffix = path.to_uppercase();
+ let current = format!("IGGY{suffix}");
+ let legacy = format!("IGGY_SYSTEM{suffix}");
+ output.push_str(&format!("if [ \"${{{current}+set}}\" = set ]; then export {legacy}=\"${{{current}}}\"; unset {current}; fi\n"));
+ }
+ }
+ let mut script = String::from("#!/bin/sh\n");
+ if let Some(system) = baseline.get("system") {
+ mappings(system, "", &mut script);
+ }
+ script.push_str(&format!(
+ "exec '{}' \"$@\"\n",
+ binary.replace('\'', "'\\''")
+ ));
+ let path = directory.join("baseline-server");
+ fs::write(&path, script).unwrap();
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
+ path
+}
+
/// Options of the topic that carries the segment chain. Every key but
/// `compression_algorithm` is sent, so that one must come back derived while
/// the rest come back explicit.
@@ -1012,7 +1106,7 @@
MAX_TOPIC_SIZE_BYTES,
))),
segment_size: Some(IggyByteSize::from(SEGMENT_SIZE_BYTES)),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(1),
size_of_messages_required_to_save: Some(IggyByteSize::from(FLUSH_SIZE_BYTES)),
// Left at the default, so only its provenance flag can tell a
@@ -1027,7 +1121,7 @@
/// Options of the topic created after the checkpoint. Different values from
/// [`data_topic_options`] where a key has a usable one, so a record attributed
/// to the wrong topic cannot pass, and a different unsent key
-/// (`enforce_fsync`) for the provenance check.
+/// (`durability`) for the provenance check.
fn wal_tail_topic_options() -> TopicCreateOptions {
TopicCreateOptions {
partitions_count: Some(PARTITIONS_COUNT),
@@ -1319,7 +1413,8 @@
message_expiry: seed.message_expiry.map(|_| topic.message_expiry),
max_topic_size: seed.max_topic_size.map(|_| topic.max_topic_size),
segment_size: seed.segment_size.and(recovered.segment_size),
- enforce_fsync: seed.enforce_fsync.and(recovered.enforce_fsync),
+ durability: recovered.durability,
+ consumer_offset_durability: recovered.consumer_offset_durability,
messages_required_to_save: seed
.messages_required_to_save
.and(recovered.messages_required_to_save),
@@ -1332,7 +1427,12 @@
raw: BTreeMap::new(),
};
assert_eq!(
- &recovered, seed,
+ &recovered,
+ &TopicCreateOptions {
+ durability: Durability::Replicated,
+ consumer_offset_durability: Durability::Replicated,
+ ..seed.clone()
+ },
"{name}: every seeded value must read back as the baseline stored it (recovered left, \
seed right); a lost key silently reverts to the shard-wide default with no log line"
);
@@ -1352,10 +1452,6 @@
),
(topic_option_keys::SEGMENT_SIZE, seed.segment_size.is_some()),
(
- topic_option_keys::ENFORCE_FSYNC,
- seed.enforce_fsync.is_some(),
- ),
- (
topic_option_keys::MESSAGES_REQUIRED_TO_SAVE,
seed.messages_required_to_save.is_some(),
),
@@ -1553,3 +1649,41 @@
}
}
}
+
+#[test]
+fn baseline_configuration_translation_preserves_supported_values_and_removes_retired_keys() {
+ let baseline: toml::Value = toml::from_str(
+ r#"
+[system]
+path = "baseline-data"
+[system.partition]
+path = "custom-partitions"
+validate_checksum = false
+[system.segment]
+archive_expired = false
+[partition]
+consumer_offset_enforce_fsync = true
+prepare_queue_depth = 8
+"#,
+ )
+ .unwrap();
+ let current: toml::Value = toml::from_str(include_str!("../../../server/config.toml")).unwrap();
+ let translated = replacement_configuration(&baseline, current);
+ assert!(translated.get("system").is_none());
+ assert_eq!(translated["path"].as_str(), Some("baseline-data"));
+ assert!(translated["partition"].get("path").is_none());
+ assert_eq!(
+ translated["partition"]["prepare_queue_depth"].as_integer(),
+ Some(8)
+ );
+ assert_eq!(
+ translated["partition"]["validate_checksum"].as_bool(),
+ Some(false)
+ );
+ assert!(
+ translated["partition"]
+ .get("consumer_offset_enforce_fsync")
+ .is_none()
+ );
+ assert!(translated.get("segment").is_none());
+}
diff --git a/core/integration/tests/sdk/options.rs b/core/integration/tests/sdk/options.rs
index 16b46fa..2fba926 100644
--- a/core/integration/tests/sdk/options.rs
+++ b/core/integration/tests/sdk/options.rs
@@ -67,7 +67,7 @@
"knob-topic",
&TopicCreateOptions {
partitions_count: Some(1),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(7),
size_of_messages_required_to_save: Some(IggyByteSize::from(4096u64)),
segment_size: Some(IggyByteSize::from(1024 * 1024u64)),
@@ -82,8 +82,11 @@
.await
.unwrap()
.expect("topic exists");
+ // The SDK sends both durability fields, including the independently
+ // defaulted offset policy. Explicit provenance describes those wire keys.
for (key, expected_explicit) in [
- (topic_option_keys::ENFORCE_FSYNC, true),
+ (topic_option_keys::DURABILITY, true),
+ (topic_option_keys::CONSUMER_OFFSET_DURABILITY, true),
(topic_option_keys::MESSAGES_REQUIRED_TO_SAVE, true),
(topic_option_keys::SIZE_OF_MESSAGES_REQUIRED_TO_SAVE, true),
// Never sent, so admission derived it from the built-in default.
@@ -277,7 +280,7 @@
&TopicCreateOptions {
partitions_count: Some(1),
segment_size: Some(IggyByteSize::from(1024 * 1024u64)),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
..TopicCreateOptions::default()
},
)
@@ -317,10 +320,12 @@
.await
.unwrap()
.expect("topic exists");
- // The keys the update did not mention keep the values create resolved.
+ // The update preserves creation provenance. Both durability defaults were
+ // sent by the SDK at creation, whereas preallocate_segments was omitted.
for (key, expected_explicit) in [
(topic_option_keys::SEGMENT_SIZE, true),
- (topic_option_keys::ENFORCE_FSYNC, true),
+ (topic_option_keys::DURABILITY, true),
+ (topic_option_keys::CONSUMER_OFFSET_DURABILITY, true),
(topic_option_keys::PREALLOCATE_SEGMENTS, false),
] {
let option = details
diff --git a/core/integration/tests/server/a2a_jwt/config.toml b/core/integration/tests/server/a2a_jwt/config.toml
index 5c08041..826379e 100644
--- a/core/integration/tests/server/a2a_jwt/config.toml
+++ b/core/integration/tests/server/a2a_jwt/config.toml
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+path = "local_data"
+
[http]
enabled = true
address = "127.0.0.1:0"
@@ -33,34 +35,3 @@
audience = "iggy"
jwks_url = "http://localhost:8080/.well-known/jwks.json"
user_id = 1
-
-[system]
-path = "local_data"
-
-[system.database]
-type = "file"
-path = "local_data/db"
-
-[system.message_bus]
-type = "in_memory"
-
-[system.streams]
-path = "local_data/streams"
-
-[system.topics]
-path = "local_data/topics"
-
-[system.partitions]
-path = "local_data/partitions"
-
-[system.segments]
-path = "local_data/segments"
-
-[system.users]
-path = "local_data/users"
-
-[system.personal_access_tokens]
-path = "local_data/pats"
-
-[system.consumer_groups]
-path = "local_data/consumer_groups"
diff --git a/core/integration/tests/server/cluster_metadata_vsr.rs b/core/integration/tests/server/cluster_metadata_vsr.rs
index 2011f6f..b841d23 100644
--- a/core/integration/tests/server/cluster_metadata_vsr.rs
+++ b/core/integration/tests/server/cluster_metadata_vsr.rs
@@ -45,7 +45,7 @@
/// where the metadata consensus that marks the leader lives. A request
/// round-robined to a peer shard would still get the full roster but with no
/// leader marked, since peer shards run no consensus.
-#[iggy_harness(cluster_nodes = 2, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 2, server(sharding.cpu_allocation = "0..1"))]
async fn given_two_node_cluster_when_getting_cluster_metadata_should_return_full_roster(
harness: &TestHarness,
) {
diff --git a/core/integration/tests/server/cluster_view_durability_vsr.rs b/core/integration/tests/server/cluster_view_durability_vsr.rs
index bf59636..6ae1024 100644
--- a/core/integration/tests/server/cluster_view_durability_vsr.rs
+++ b/core/integration/tests/server/cluster_view_durability_vsr.rs
@@ -61,7 +61,7 @@
const CONVERGE_TIMEOUT: Duration = Duration::from_secs(60);
const POLL_INTERVAL: Duration = Duration::from_millis(250);
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_advanced_metadata_view_when_survivor_restarts_should_recover_view_from_superblock(
harness: &mut TestHarness,
) {
diff --git a/core/integration/tests/server/http_read_your_writes.rs b/core/integration/tests/server/http_read_your_writes.rs
index 5fa54d6..f428f79 100644
--- a/core/integration/tests/server/http_read_your_writes.rs
+++ b/core/integration/tests/server/http_read_your_writes.rs
@@ -108,7 +108,7 @@
/// follower-to-primary forwarding is off, so the follower answers its own
/// requests instead of relaying them (see `http_view_header`, which pins both
/// halves of that switch).
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_follower_when_its_register_binds_a_committed_epoch_should_not_read_below_it(
harness: &TestHarness,
) {
@@ -170,7 +170,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
)
diff --git a/core/integration/tests/server/http_view_header.rs b/core/integration/tests/server/http_view_header.rs
index ca21921..efc9a54 100644
--- a/core/integration/tests/server/http_view_header.rs
+++ b/core/integration/tests/server/http_view_header.rs
@@ -98,7 +98,7 @@
/// metadata consensus lives (see cluster_metadata_vsr.rs). No `http.jwt`
/// secret and no `cluster.auth`: bearers are node-local, forwarding is off,
/// and a follower answers a linearizable read with the 307 primary redirect.
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_a_follower_when_it_redirects_a_linearizable_read_should_carry_the_iggy_view_header(
harness: &TestHarness,
) {
@@ -132,7 +132,7 @@
#[iggy_harness(
cluster_nodes = 3,
server(
- system.sharding.cpu_allocation = "0..1",
+ sharding.cpu_allocation = "0..1",
http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
)
diff --git a/core/integration/tests/server/http_vsr.rs b/core/integration/tests/server/http_vsr.rs
index 0438937..985bc93 100644
--- a/core/integration/tests/server/http_vsr.rs
+++ b/core/integration/tests/server/http_vsr.rs
@@ -45,7 +45,7 @@
const CONSUMER_ID: u32 = 1;
const DURABILITY_HEADER: &str = "iggy-durability";
-const DURABILITY_REPLICATED_MEMORY: &str = "replicated-memory";
+const DURABILITY_REPLICATED: &str = "replicated";
const DURABILITY_NONE: &str = "none";
/// `?ack=none` answers before the commit; poll until visible, never unbounded.
@@ -297,7 +297,7 @@
StatusCode::CREATED,
"produce must commit"
);
- assert_eq!(durability(&response), DURABILITY_REPLICATED_MEMORY);
+ assert_eq!(durability(&response), DURABILITY_REPLICATED);
let polled = http
.poll("http-stream", "http-topic", PARTITION_ID, 0, 10)
@@ -353,7 +353,7 @@
"concurrent produce {i} must commit"
);
assert_eq!(
- durability, DURABILITY_REPLICATED_MEMORY,
+ durability, DURABILITY_REPLICATED,
"concurrent produce {i} must attest a replicated commit"
);
}
@@ -991,3 +991,155 @@
);
}
}
+
+#[iggy_harness]
+async fn given_independent_durability_policies_when_producing_should_attest_message_policy(
+ harness: &TestHarness,
+) {
+ let client = harness.tcp_root_client().await.unwrap();
+ client.create_stream("durability-http").await.unwrap();
+ let stream = Identifier::named("durability-http").unwrap();
+ let http = HttpClient::login_root(harness).await;
+ for (index, (message_policy, offset_policy)) in [
+ (Durability::Replicated, Durability::Replicated),
+ (Durability::Replicated, Durability::Persisted),
+ (Durability::Persisted, Durability::Replicated),
+ (Durability::Persisted, Durability::Persisted),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ let topic = format!("policy-{index}");
+ let created = http
+ .client
+ .post(http.url("/streams/durability-http/topics"))
+ .bearer_auth(&http.token)
+ .json(&CreateTopic {
+ name: topic.clone(),
+ options: BTreeMap::from([
+ ("durability".to_string(), message_policy.to_string()),
+ (
+ "consumer_offset_durability".to_string(),
+ offset_policy.to_string(),
+ ),
+ ]),
+ ..CreateTopic::default()
+ })
+ .send()
+ .await
+ .unwrap();
+ assert!(
+ created.status().is_success(),
+ "{}",
+ created.text().await.unwrap()
+ );
+ let details = client
+ .get_topic(&stream, &Identifier::named(&topic).unwrap())
+ .await
+ .unwrap()
+ .unwrap();
+ let policies = iggy_common::TopicRuntimeOptions::from_resource_options(&details.options);
+ assert_eq!(policies.durability, message_policy);
+ assert_eq!(policies.consumer_offset_durability, offset_policy);
+ for (query, status, expected) in [
+ ("", StatusCode::CREATED, message_policy.as_ref()),
+ (
+ "?ack=replicated",
+ StatusCode::CREATED,
+ message_policy.as_ref(),
+ ),
+ ("?ack=none", StatusCode::ACCEPTED, "none"),
+ ] {
+ let message = IggyMessage::builder()
+ .payload("policy".into())
+ .build()
+ .unwrap();
+ let response = http
+ .produce_with_query(
+ "durability-http",
+ &topic,
+ PARTITION_ID,
+ vec![message],
+ query,
+ )
+ .await;
+ assert_eq!(response.status(), status);
+ assert_eq!(durability(&response), expected);
+ }
+ }
+}
+
+#[iggy_harness]
+async fn given_one_http_durability_option_when_creating_should_derive_the_other_as_replicated(
+ harness: &TestHarness,
+) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-policy-defaults", "initial", 1)
+ .await;
+ for key in ["durability", "consumer_offset_durability"] {
+ let response = http
+ .client
+ .post(http.url("/streams/http-policy-defaults/topics"))
+ .bearer_auth(&http.token)
+ .json(&CreateTopic {
+ name: key.to_string(),
+ options: BTreeMap::from([(key.to_string(), "persisted".to_string())]),
+ ..CreateTopic::default()
+ })
+ .send()
+ .await
+ .unwrap();
+ assert!(response.status().is_success());
+ let topic: serde_json::Value = response.json().await.unwrap();
+ for policy in ["durability", "consumer_offset_durability"] {
+ assert_eq!(
+ topic["options"][policy]["value"],
+ if policy == key {
+ "persisted"
+ } else {
+ "replicated"
+ }
+ );
+ assert_eq!(topic["options"][policy]["explicit"], policy == key);
+ }
+ }
+}
+
+#[iggy_harness]
+async fn given_http_sdk_when_sending_should_expose_the_advertised_durability(
+ harness: &TestHarness,
+) {
+ let address = harness.server().http_addr().unwrap();
+ let client = iggy::http::http_client::HttpClient::new(&format!("http://{address}")).unwrap();
+ client.login_user("iggy", "iggy").await.unwrap();
+ client.create_stream("sdk-durability").await.unwrap();
+ let stream = Identifier::named("sdk-durability").unwrap();
+ client
+ .create_topic(
+ &stream,
+ "persisted",
+ &TopicCreateOptions {
+ durability: Durability::Persisted,
+ ..TopicCreateOptions::default()
+ },
+ )
+ .await
+ .unwrap();
+ let mut messages = vec![
+ IggyMessage::builder()
+ .payload(bytes::Bytes::from_static(b"durable"))
+ .build()
+ .unwrap(),
+ ];
+ let (response, durability) = client
+ .send_messages_with_durability(
+ &stream,
+ &Identifier::named("persisted").unwrap(),
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+ assert_eq!(durability, Some(Durability::Persisted));
+ assert_eq!(response.confirmations.len(), 1);
+}
diff --git a/core/integration/tests/server/message_retrieval.rs b/core/integration/tests/server/message_retrieval.rs
index 8b1d859..0cbba84 100644
--- a/core/integration/tests/server/message_retrieval.rs
+++ b/core/integration/tests/server/message_retrieval.rs
@@ -55,7 +55,7 @@
}
/// The two axes that used to be `[system.segment] size` and
-/// `[system.partition] messages_required_to_save` are topic creation options
+/// `[partition] messages_required_to_save` are topic creation options
/// now, so they travel with the topic the scenario creates rather than with
/// the server.
fn topic_options(segment_size: u64, messages_required_to_save: u32) -> TopicCreateOptions {
diff --git a/core/integration/tests/server/partition_view_durability_vsr.rs b/core/integration/tests/server/partition_view_durability_vsr.rs
index 1e8a948..61d25c9 100644
--- a/core/integration/tests/server/partition_view_durability_vsr.rs
+++ b/core/integration/tests/server/partition_view_durability_vsr.rs
@@ -59,7 +59,7 @@
const RESTORED_VIEW_MARKER: &str = "restored group view from its superblock";
const POLL_INTERVAL: Duration = Duration::from_millis(250);
-#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
async fn given_advanced_partition_view_when_survivor_restarts_should_recover_view_from_superblock(
harness: &mut TestHarness,
) {
diff --git a/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs b/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
index 749399b..b5adb73 100644
--- a/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
+++ b/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
@@ -67,7 +67,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- enforce_fsync: Some(false),
+ durability: iggy_common::Durability::Replicated,
..TopicCreateOptions::default()
},
)
diff --git a/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
index 2f321f0..152cc21 100644
--- a/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
@@ -24,8 +24,12 @@
use std::str::{FromStr, from_utf8};
pub async fn run(harness: &TestHarness) {
+ // The metadata leader can change while the consumer clients are polling.
let system_client = harness
- .root_client()
+ .client_builder_for(harness.transport().expect("Failed to get test transport"))
+ .expect("Failed to create client builder")
+ .with_reconnecting_root_login()
+ .connect()
.await
.expect("Failed to get root client");
let client1 = create_client(harness).await;
diff --git a/core/integration/tests/server/scenarios/encryption_scenario.rs b/core/integration/tests/server/scenarios/encryption_scenario.rs
index f62425f..a59c515 100644
--- a/core/integration/tests/server/scenarios/encryption_scenario.rs
+++ b/core/integration/tests/server/scenarios/encryption_scenario.rs
@@ -482,7 +482,7 @@
/// the segment files directly. Both knobs are topic creation options now.
fn eager_flush_options() -> TopicCreateOptions {
TopicCreateOptions {
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(1),
..TopicCreateOptions::default()
}
@@ -492,12 +492,9 @@
let mut extra_envs = HashMap::new();
if encryption {
+ extra_envs.insert("IGGY_ENCRYPTION_ENABLED".to_string(), "true".to_string());
extra_envs.insert(
- "IGGY_SYSTEM_ENCRYPTION_ENABLED".to_string(),
- "true".to_string(),
- );
- extra_envs.insert(
- "IGGY_SYSTEM_ENCRYPTION_KEY".to_string(),
+ "IGGY_ENCRYPTION_KEY".to_string(),
"/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE=".to_string(),
);
}
diff --git a/core/integration/tests/server/scenarios/log_rotation_scenario.rs b/core/integration/tests/server/scenarios/log_rotation_scenario.rs
index b2c3f3c..42820e2 100644
--- a/core/integration/tests/server/scenarios/log_rotation_scenario.rs
+++ b/core/integration/tests/server/scenarios/log_rotation_scenario.rs
@@ -89,19 +89,19 @@
fn build_server_config(log_config: &LogRotationTestConfig) -> TestServerConfig {
let mut extra_envs = HashMap::new();
extra_envs.insert(
- "IGGY_SYSTEM_LOGGING_MAX_FILE_SIZE".to_string(),
+ "IGGY_LOGGING_MAX_FILE_SIZE".to_string(),
format!("{}", log_config.max_single_log_size),
);
extra_envs.insert(
- "IGGY_SYSTEM_LOGGING_MAX_TOTAL_SIZE".to_string(),
+ "IGGY_LOGGING_MAX_TOTAL_SIZE".to_string(),
format!("{}", log_config.max_total_log_size),
);
extra_envs.insert(
- "IGGY_SYSTEM_LOGGING_ROTATION_CHECK_INTERVAL".to_string(),
+ "IGGY_LOGGING_ROTATION_CHECK_INTERVAL".to_string(),
format!("{}", log_config.rotation_check_interval),
);
extra_envs.insert(
- "IGGY_SYSTEM_LOGGING_RETENTION".to_string(),
+ "IGGY_LOGGING_RETENTION".to_string(),
format!("{}", log_config.retention),
);
diff --git a/core/integration/tests/server/scenarios/message_cleanup_scenario.rs b/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
index 6c327a8..d7fab23 100644
--- a/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
+++ b/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
@@ -45,6 +45,10 @@
/// 256 + 10 * (48 + 105000) = 1050736 >= 1 MiB.
const PAYLOAD_SIZE: usize = 105_000;
+const MESSAGE_EXPIRY: Duration = Duration::from_millis(100);
+const CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
+const CLEANUP_POLL_INTERVAL: Duration = Duration::from_millis(100);
+
/// Buffer time for cleaner to run after expiry conditions are met.
const CLEANER_BUFFER: Duration = Duration::from_millis(300);
@@ -59,8 +63,9 @@
fn cleanup_topic_options() -> TopicCreateOptions {
TopicCreateOptions {
partitions_count: Some(1),
+ message_expiry: Some(IggyExpiry::NeverExpire),
segment_size: Some(IggyByteSize::from(SEGMENT_SIZE)),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(1),
..TopicCreateOptions::default()
}
@@ -71,21 +76,11 @@
let stream = client.create_stream(STREAM_NAME).await.unwrap();
let stream_id = stream.id;
- // The whole send burst has to land inside this window: expiry runs off each
- // message's own timestamp, so a produce run that outlives it gets its
- // oldest segments reclaimed before the pre-expiry poll ever runs. A 3-node
- // vsr cluster in a debug build pays a consensus round-trip plus an fsync
- // per request, which is what pushed the old one-message-per-request loop
- // past the old 2s window.
- let expiry = Duration::from_secs(4);
let topic = client
.create_topic(
&Identifier::named(STREAM_NAME).unwrap(),
TOPIC_NAME,
- &TopicCreateOptions {
- message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(expiry))),
- ..cleanup_topic_options()
- },
+ &cleanup_topic_options(),
)
.await
.unwrap();
@@ -98,9 +93,6 @@
.display()
.to_string();
- // Send 40 messages in batches, spanning several 1 MiB segments.
- // Batched rather than one request per message: the burst must fit inside
- // `expiry` with room to spare, and each request costs a round-trip.
let payload = make_payload('A');
let total_messages: usize = 40;
let batch_size = 10;
@@ -155,18 +147,8 @@
"Should poll all messages before expiry"
);
- // Wait for expiry + cleaner
- tokio::time::sleep(expiry + CLEANER_BUFFER).await;
-
- let remaining_segments = get_segment_paths_for_partition(&partition_path);
- assert!(
- remaining_segments.len() < initial_count,
- "Expected segments to be deleted after expiry"
- );
- assert!(
- !remaining_segments.is_empty(),
- "Active segment should not be deleted"
- );
+ expire_messages(client, STREAM_NAME, TOPIC_NAME).await;
+ wait_for_segment_cleanup(&partition_path, initial_count).await;
// Verify fewer messages available after cleanup
let polled_after = client
@@ -358,13 +340,11 @@
let stream = client.create_stream(STREAM_NAME).await.unwrap();
let stream_id = stream.id;
- let expiry = Duration::from_secs(2);
let topic = client
.create_topic(
&Identifier::named(STREAM_NAME).unwrap(),
TOPIC_NAME,
&TopicCreateOptions {
- message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(expiry))),
// 500 MiB (won't trigger)
max_topic_size: Some(MaxTopicSize::Custom(IggyByteSize::from(500 * 1024 * 1024))),
..cleanup_topic_options()
@@ -381,10 +361,6 @@
.display()
.to_string();
- // Send 40 messages to create several segments (under size threshold, but
- // will expire). Batched so the burst finishes well inside `expiry`: a
- // per-message request pays a consensus round-trip plus an fsync, and a loop
- // that outlives the window has its head reclaimed before the count below.
let payload = make_payload('C');
let total_messages: usize = 40;
let batch_size = 10;
@@ -414,18 +390,8 @@
let initial_count = initial_segments.len();
assert!(initial_count >= 2, "Expected at least 2 segments");
- // Wait for time-based expiry
- tokio::time::sleep(expiry + CLEANER_BUFFER).await;
-
- let remaining_segments = get_segment_paths_for_partition(&partition_path);
- assert!(
- remaining_segments.len() < initial_count,
- "Segments should be deleted after expiry"
- );
- assert!(
- !remaining_segments.is_empty(),
- "Active segment should not be deleted"
- );
+ expire_messages(client, STREAM_NAME, TOPIC_NAME).await;
+ wait_for_segment_cleanup(&partition_path, initial_count).await;
client
.delete_stream(&Identifier::named(STREAM_NAME).unwrap())
@@ -440,14 +406,12 @@
let stream = client.create_stream(STREAM_NAME).await.unwrap();
let stream_id = stream.id;
- let expiry = Duration::from_secs(5);
let topic = client
.create_topic(
&Identifier::named(STREAM_NAME).unwrap(),
TOPIC_NAME,
&TopicCreateOptions {
partitions_count: Some(PARTITIONS_COUNT),
- message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(expiry))),
..cleanup_topic_options()
},
)
@@ -459,10 +423,6 @@
let messages_per_partition: usize = 40;
let batch_size = 10;
- // Send messages to all partitions. Batched: a per-message request costs a
- // consensus round-trip plus an fsync, and `PARTITIONS_COUNT` × 110 of those
- // outlive `expiry`, so the cleaner would reclaim the first partition's
- // sealed segments before the last one had even been written.
for partition_id in 0..PARTITIONS_COUNT {
for chunk_start in (0..messages_per_partition).step_by(batch_size) {
let mut messages: Vec<IggyMessage> = (chunk_start
@@ -490,7 +450,6 @@
// Collect initial segment counts
let mut initial_counts: Vec<usize> = Vec::new();
- // Wait until all partitions have >= 2 segments (up to 5s)
for partition_id in 0..PARTITIONS_COUNT {
let partition_path = data_path
.join(format!(
@@ -499,7 +458,7 @@
.display()
.to_string();
- let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
+ let deadline = tokio::time::Instant::now() + CLEANUP_TIMEOUT;
let count = loop {
let segments = get_segment_paths_for_partition(&partition_path);
if segments.len() >= 2 {
@@ -507,21 +466,17 @@
}
if tokio::time::Instant::now() >= deadline {
panic!(
- "Partition {} should have at least 2 segments after 5s, got {}",
- partition_id,
+ "Partition {partition_id} should have at least 2 segments after {CLEANUP_TIMEOUT:?}, got {}",
segments.len()
);
}
- tokio::time::sleep(Duration::from_millis(100)).await;
+ tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
};
initial_counts.push(count);
}
- // Wait for expiry + cleaner
- tokio::time::sleep(expiry + CLEANER_BUFFER).await;
+ expire_messages(client, STREAM_NAME, TOPIC_NAME).await;
- // Verify cleanup in all partitions
- let mut total_deleted = 0usize;
for partition_id in 0..PARTITIONS_COUNT {
let partition_path = data_path
.join(format!(
@@ -529,22 +484,9 @@
))
.display()
.to_string();
- let remaining = get_segment_paths_for_partition(&partition_path);
- let deleted = initial_counts[partition_id as usize].saturating_sub(remaining.len());
- total_deleted += deleted;
-
- assert!(
- !remaining.is_empty(),
- "Partition {} should retain active segment",
- partition_id
- );
+ wait_for_segment_cleanup(&partition_path, initial_counts[partition_id as usize]).await;
}
- assert!(
- total_deleted > 0,
- "At least some segments should be deleted"
- );
-
client
.delete_stream(&Identifier::named(STREAM_NAME).unwrap())
.await
@@ -675,12 +617,11 @@
///
/// Scenario:
/// 1. Send 100 messages (several 1 MiB segments)
-/// 2. Consumer reads only 50 messages (stored offset ~49, within segment 0)
-/// 3. Wait for all segments to expire (4s expiry)
+/// 2. Consumer reads only 50 messages and stores offset 49
+/// 3. Enable expiry and wait for consumed segments to be removed
/// 4. Verify consumer can still poll Next() and get contiguous offsets
///
-/// On unfixed code: the cleaner deletes segments 0+1 (expired, consumer offset
-/// not checked), consumer's Next() jumps to segment 2, skipping ~250 messages.
+/// Without the consumer barrier, cleanup also removes the unconsumed segments.
pub async fn run_expiry_respects_consumer_offset(client: &IggyClient, data_path: &Path) {
const TEST_STREAM: &str = "test_cleaner_barrier_stream";
const TEST_TOPIC: &str = "test_cleaner_barrier_topic";
@@ -688,20 +629,11 @@
let stream = client.create_stream(TEST_STREAM).await.unwrap();
let stream_id = stream.id;
- // Expiry must outlast the send + first-poll phase. If segments expire
- // before the consumer commits its first offset, there is no barrier yet
- // and the cleaner legally deletes them, breaking the premise: the poll
- // below then starts at the earliest surviving offset instead of 0.
- // The sends are batched for the same reason (see below).
- let expiry = Duration::from_secs(4);
let topic = client
.create_topic(
&Identifier::named(TEST_STREAM).unwrap(),
TEST_TOPIC,
- &TopicCreateOptions {
- message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(expiry))),
- ..cleanup_topic_options()
- },
+ &cleanup_topic_options(),
)
.await
.unwrap();
@@ -714,9 +646,6 @@
.display()
.to_string();
- // Send 100 messages -> several sealed segments + active. Batched: one
- // request per message costs a consensus round-trip plus an fsync each,
- // which on a 3-node debug cluster runs the burst well past `expiry`.
let payload = make_payload('B');
let total_messages = 100u32;
let batch_size = 10u32;
@@ -749,8 +678,6 @@
initial_segments.len()
);
- // Consumer reads only 50 messages with auto_commit, storing offset ~49.
- // This means the consumer has NOT read segments 1, 2, etc.
let consumer = Consumer::new(Identifier::numeric(42).unwrap());
let mut consumed_offsets = Vec::new();
let mut remaining = 50u32;
@@ -768,6 +695,10 @@
)
.await
.unwrap();
+ assert!(
+ !polled.messages.is_empty(),
+ "messages must remain available before expiry is enabled"
+ );
for msg in &polled.messages {
consumed_offsets.push(msg.header.offset);
}
@@ -779,8 +710,8 @@
"Consumer should have read through offset 49"
);
- // Wait for expiry + cleaner buffer
- tokio::time::sleep(expiry + CLEANER_BUFFER + CLEANER_BUFFER).await;
+ expire_messages(client, TEST_STREAM, TEST_TOPIC).await;
+ wait_for_segment_cleanup(&partition_path, initial_segments.len()).await;
// Now poll Next() - consumer should continue from offset 50 without gaps.
// BUG: on unfixed code, the cleaner deleted the segment holding offset 50
@@ -820,6 +751,41 @@
.unwrap();
}
+async fn expire_messages(client: &IggyClient, stream: &str, topic: &str) {
+ // Slow setup must not consume the expiry window before the assertions are ready.
+ client
+ .update_topic(
+ &Identifier::named(stream).unwrap(),
+ &Identifier::named(topic).unwrap(),
+ topic,
+ &TopicUpdateOptions {
+ message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(
+ MESSAGE_EXPIRY,
+ ))),
+ ..TopicUpdateOptions::default()
+ },
+ )
+ .await
+ .unwrap();
+ // Every previously sent batch must be eligible, including unconsumed batches.
+ tokio::time::sleep(MESSAGE_EXPIRY).await;
+}
+
+async fn wait_for_segment_cleanup(partition_path: &str, initial_count: usize) {
+ let deadline = tokio::time::Instant::now() + CLEANUP_TIMEOUT;
+ loop {
+ let remaining = get_segment_paths_for_partition(partition_path).len();
+ if remaining > 0 && remaining < initial_count {
+ return;
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "cleanup must remove sealed segments and retain the active segment in {partition_path}: initial={initial_count}, remaining={remaining} after {CLEANUP_TIMEOUT:?}"
+ );
+ tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
+ }
+}
+
fn get_segment_paths_for_partition(partition_path: &str) -> Vec<DirEntry> {
read_dir(partition_path)
.map(|read_dir| {
diff --git a/core/integration/tests/server/scenarios/purge_delete_scenario.rs b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
index b29130e..9bf39c8 100644
--- a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
+++ b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
@@ -67,7 +67,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
segment_size: Some(IggyByteSize::from(SEGMENT_SIZE)),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(1),
..TopicCreateOptions::default()
}
diff --git a/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs b/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
index dee7bf5..8861b83 100644
--- a/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
+++ b/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
@@ -47,7 +47,7 @@
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(100_000),
size_of_messages_required_to_save: Some(IggyByteSize::from(1024 * 1024 * 1024u64)),
- enforce_fsync: Some(false),
+ durability: iggy_common::Durability::Replicated,
..TopicCreateOptions::default()
},
)
diff --git a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
index b7b48d3..33d3e7a 100644
--- a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
+++ b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
@@ -27,12 +27,12 @@
/// The restart specs below need every committed batch on disk before the
/// server goes down: there is no flush primitive, so the topic carries the
-/// eager-flush thresholds that used to be `[system.partition]` config.
+/// eager-flush thresholds that used to be `[partition]` config.
fn eager_flush_options() -> TopicCreateOptions {
TopicCreateOptions {
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
- enforce_fsync: Some(true),
+ durability: iggy_common::Durability::Persisted,
messages_required_to_save: Some(1),
..TopicCreateOptions::default()
}
diff --git a/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs b/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
index f3f557f..c97b425 100644
--- a/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
+++ b/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
@@ -68,7 +68,7 @@
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(10_000),
- enforce_fsync: Some(false),
+ durability: iggy_common::Durability::Replicated,
..TopicCreateOptions::default()
},
)
diff --git a/core/journal/Cargo.toml b/core/journal/Cargo.toml
index ed2130f..01443d4 100644
--- a/core/journal/Cargo.toml
+++ b/core/journal/Cargo.toml
@@ -31,15 +31,19 @@
[dependencies]
bytemuck = { workspace = true }
compio = { workspace = true }
+futures = { workspace = true }
iggy_binary_protocol = { workspace = true }
+iggy_common = { workspace = true }
server_common = { workspace = true }
tracing = { workspace = true }
twox-hash = { workspace = true }
[dev-dependencies]
-futures = { workspace = true }
tempfile = { workspace = true }
+[target.'cfg(target_os = "linux")'.dev-dependencies]
+nix = { workspace = true }
+
[lints.clippy]
enum_glob_use = "deny"
pedantic = "deny"
diff --git a/core/journal/src/durable_storage.rs b/core/journal/src/durable_storage.rs
new file mode 100644
index 0000000..3ac3cbc
--- /dev/null
+++ b/core/journal/src/durable_storage.rs
@@ -0,0 +1,452 @@
+// 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.
+
+#![allow(clippy::future_not_send)]
+
+use compio::buf::{IntoInner, IoBuf};
+use compio::fs::{File, OpenOptions};
+use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
+use futures::channel::oneshot;
+use futures::lock::Mutex;
+use server_common::iobuf::{Frozen, Owned};
+use std::ffi::OsString;
+use std::io;
+use std::path::Path;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OpenMode {
+ Read,
+ ReadWrite,
+ Create,
+ /// Create a file if absent, preserving the inode and bytes if it exists.
+ CreateOrOpen,
+}
+
+pub struct StorageEntry {
+ pub name: OsString,
+ pub directory: bool,
+}
+
+/// Filesystem operations whose completion and persistence order affect recovery.
+/// Implementations must preserve open file identity across rename and unlink.
+pub trait DurableStorage {
+ type File: DurableFile;
+
+ /// Process-local identity for exclusive writer ownership.
+ ///
+ /// # Errors
+ /// Returns an error if the absolute identity cannot be resolved.
+ fn writer_identity(&self, _path: &Path) -> io::Result<Option<std::path::PathBuf>> {
+ Ok(None)
+ }
+
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn open(&self, path: &Path, mode: OpenMode) -> impl Future<Output = io::Result<Self::File>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn create_directories(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn sync_directory(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn rename(&self, source: &Path, target: &Path) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn remove_file(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn hard_link(&self, source: &Path, target: &Path) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn exists(&self, path: &Path) -> impl Future<Output = io::Result<bool>>;
+ /// # Errors
+ /// Returns an error for unreadable directories or unsupported file types.
+ fn entries(&self, path: &Path) -> impl Future<Output = io::Result<Vec<StorageEntry>>>;
+ /// # Errors
+ /// Returns the underlying filesystem error. Missing paths are accepted.
+ fn remove_tree(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
+}
+
+pub trait DurableFile {
+ /// Best-effort reservation that must not change bytes or logical length.
+ /// Backends without physical allocation may ignore this hint.
+ fn preallocate(&self, _path: &Path, _length: u64) {}
+
+ /// # Errors
+ /// Returns an error if the complete range cannot be read.
+ fn read(&self, offset: u64, length: usize) -> impl Future<Output = io::Result<Vec<u8>>>;
+ /// # Errors
+ /// Returns an error if any part of the write fails.
+ fn write(&mut self, offset: u64, bytes: Vec<u8>) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns an error if the immutable extent cannot be written completely.
+ fn write_frozen(
+ &mut self,
+ offset: u64,
+ bytes: Frozen<4096>,
+ ) -> impl Future<Output = io::Result<()>> {
+ async move { self.write(offset, bytes.as_slice().to_vec()).await }
+ }
+ /// Write adjacent immutable extents in order.
+ /// Callers must limit the buffer count to [`server_common::iobuf::IOV_MAX`].
+ ///
+ /// # Errors
+ /// Returns an error if any extent cannot be written completely.
+ fn write_frozen_vectored(
+ &mut self,
+ offset: u64,
+ buffers: Vec<Frozen<4096>>,
+ ) -> impl Future<Output = io::Result<()>> {
+ async move {
+ let mut bytes = Vec::with_capacity(buffers.iter().map(Frozen::len).sum());
+ for buffer in buffers {
+ bytes.extend_from_slice(buffer.as_slice());
+ }
+ self.write(offset, bytes).await
+ }
+ }
+ /// # Errors
+ /// Returns an error if the aligned extent cannot be written completely.
+ fn write_aligned(
+ &mut self,
+ offset: u64,
+ bytes: Owned<4096>,
+ ) -> impl Future<Output = io::Result<()>> {
+ async move { self.write(offset, bytes.as_slice().to_vec()).await }
+ }
+ /// # Errors
+ /// Returns an error if the requested range cannot be read completely.
+ fn read_aligned(
+ &self,
+ offset: u64,
+ length: usize,
+ ) -> impl Future<Output = io::Result<Owned<4096>>> {
+ async move { Ok(Owned::copy_from_slice(&self.read(offset, length).await?)) }
+ }
+ /// # Errors
+ /// Returns an error if the remaining aligned range cannot be read completely.
+ fn read_aligned_tail(
+ &self,
+ offset: u64,
+ mut bytes: Owned<4096>,
+ start: usize,
+ ) -> impl Future<Output = io::Result<Owned<4096>>> {
+ async move {
+ let tail = self.read(offset, bytes.as_slice().len() - start).await?;
+ bytes.as_mut_slice()[start..].copy_from_slice(&tail);
+ Ok(bytes)
+ }
+ }
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn length(&self) -> impl Future<Output = io::Result<u64>>;
+ /// # Errors
+ /// Returns the underlying filesystem error.
+ fn truncate(&self, length: u64) -> impl Future<Output = io::Result<()>>;
+ /// # Errors
+ /// Returns an error unless prior writes and the file length are durable.
+ fn sync(&self) -> impl Future<Output = io::Result<()>>;
+}
+
+#[derive(Clone, Copy, Default)]
+pub struct DiskStorage;
+
+impl DurableStorage for DiskStorage {
+ type File = File;
+
+ fn writer_identity(&self, path: &Path) -> io::Result<Option<std::path::PathBuf>> {
+ let absolute = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ std::env::current_dir()?.join(path)
+ };
+ let mut normalized = std::path::PathBuf::new();
+ for component in absolute.components() {
+ match component {
+ std::path::Component::CurDir => {}
+ std::path::Component::ParentDir => {
+ normalized.pop();
+ }
+ component => normalized.push(component.as_os_str()),
+ }
+ }
+ Ok(Some(normalized))
+ }
+
+ async fn open(&self, path: &Path, mode: OpenMode) -> io::Result<File> {
+ let mut options = OpenOptions::new();
+ options.read(true).write(mode != OpenMode::Read);
+ if matches!(mode, OpenMode::Create | OpenMode::CreateOrOpen) {
+ options.create(true).truncate(mode == OpenMode::Create);
+ }
+ options.open(path).await
+ }
+
+ async fn create_directories(&self, path: &Path) -> io::Result<()> {
+ compio::fs::create_dir_all(path).await
+ }
+
+ async fn sync_directory(&self, path: &Path) -> io::Result<()> {
+ File::open(path).await?.sync_all().await
+ }
+
+ async fn rename(&self, source: &Path, target: &Path) -> io::Result<()> {
+ compio::fs::rename(source, target).await
+ }
+
+ async fn remove_file(&self, path: &Path) -> io::Result<()> {
+ compio::fs::remove_file(path).await
+ }
+
+ async fn hard_link(&self, source: &Path, target: &Path) -> io::Result<()> {
+ compio::fs::hard_link(source, target).await
+ }
+
+ async fn exists(&self, path: &Path) -> io::Result<bool> {
+ match compio::fs::symlink_metadata(path).await {
+ Ok(_) => Ok(true),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(error),
+ }
+ }
+
+ async fn entries(&self, path: &Path) -> io::Result<Vec<StorageEntry>> {
+ // getdents has no io_uring operation, and shard fallback pools are disabled.
+ let path = path.to_path_buf();
+ run_blocking("iggy-directory-scan", move || directory_entries(&path)).await
+ }
+
+ async fn remove_tree(&self, path: &Path) -> io::Result<()> {
+ let mut pending = vec![(path.to_path_buf(), false)];
+ while let Some((path, visited)) = pending.pop() {
+ let metadata = match compio::fs::symlink_metadata(&path).await {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
+ Err(error) => return Err(error),
+ };
+ let result = if !metadata.is_dir() {
+ compio::fs::remove_file(&path).await
+ } else if visited {
+ compio::fs::remove_dir(&path).await
+ } else {
+ let entries = self.entries(&path).await?;
+ pending.push((path.clone(), true));
+ pending.extend(
+ entries
+ .into_iter()
+ .map(|entry| (path.join(entry.name), false)),
+ );
+ continue;
+ };
+ if let Err(error) = result
+ && error.kind() != io::ErrorKind::NotFound
+ {
+ return Err(error);
+ }
+ }
+ Ok(())
+ }
+}
+
+impl DurableFile for File {
+ fn preallocate(&self, path: &Path, length: u64) {
+ server_common::fs_utils::preallocate_file(self, path, length);
+ }
+
+ async fn read(&self, offset: u64, length: usize) -> io::Result<Vec<u8>> {
+ let (result, bytes) = self.read_exact_at(vec![0; length], offset).await.into();
+ result?;
+ Ok(bytes)
+ }
+
+ async fn write(&mut self, offset: u64, bytes: Vec<u8>) -> io::Result<()> {
+ self.write_all_at(bytes, offset).await.0
+ }
+
+ async fn write_frozen(&mut self, offset: u64, bytes: Frozen<4096>) -> io::Result<()> {
+ self.write_all_at(bytes, offset).await.0
+ }
+
+ async fn write_frozen_vectored(
+ &mut self,
+ offset: u64,
+ buffers: Vec<Frozen<4096>>,
+ ) -> io::Result<()> {
+ self.write_vectored_all_at(buffers, offset).await.0
+ }
+
+ async fn write_aligned(&mut self, offset: u64, bytes: Owned<4096>) -> io::Result<()> {
+ self.write_all_at(bytes, offset).await.0
+ }
+
+ async fn read_aligned(&self, offset: u64, length: usize) -> io::Result<Owned<4096>> {
+ let (result, bytes) = self
+ .read_exact_at(Owned::with_capacity(length), offset)
+ .await
+ .into();
+ result?;
+ Ok(bytes)
+ }
+
+ async fn read_aligned_tail(
+ &self,
+ offset: u64,
+ bytes: Owned<4096>,
+ start: usize,
+ ) -> io::Result<Owned<4096>> {
+ let (result, slice) = self
+ .read_exact_at(bytes.slice(start..), offset)
+ .await
+ .into();
+ result?;
+ Ok(slice.into_inner())
+ }
+
+ async fn length(&self) -> io::Result<u64> {
+ Ok(self.metadata().await?.len())
+ }
+
+ async fn truncate(&self, length: u64) -> io::Result<()> {
+ // Older kernels lack IORING_OP_FTRUNCATE and shard fallback pools are
+ // disabled. Own the inode until the worker completes, even on cancellation.
+ let descriptor = std::os::fd::AsFd::as_fd(self).try_clone_to_owned()?;
+ run_blocking("iggy-file-truncate", move || {
+ std::fs::File::from(descriptor).set_len(length)
+ })
+ .await
+ }
+
+ async fn sync(&self) -> io::Result<()> {
+ self.sync_data().await
+ }
+}
+
+async fn run_blocking<T: Send + 'static>(
+ name: &'static str,
+ operation: impl FnOnce() -> io::Result<T> + Send + 'static,
+) -> io::Result<T> {
+ // Keep the permit on the worker: cancelling its caller must not admit
+ // another blocking operation while this one still owns filesystem state.
+ static WORKER: Mutex<()> = Mutex::new(());
+ let permit = WORKER.lock().await;
+ let (sender, receiver) = oneshot::channel();
+ std::thread::Builder::new()
+ .name(name.to_owned())
+ .spawn(move || {
+ let _permit = permit;
+ let _ = sender.send(operation());
+ })?;
+ receiver
+ .await
+ .map_err(|_| io::Error::other(format!("{name} stopped")))?
+}
+
+fn directory_entries(path: &Path) -> io::Result<Vec<StorageEntry>> {
+ std::fs::read_dir(path)?
+ .map(|entry| {
+ let entry = entry?;
+ let kind = entry.file_type()?;
+ if !(kind.is_file() || kind.is_dir()) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "unexpected storage file type",
+ ));
+ }
+ Ok(StorageEntry {
+ name: entry.file_name(),
+ directory: kind.is_dir(),
+ })
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{DiskStorage, DurableFile, DurableStorage, OpenMode, run_blocking};
+ use futures::channel::oneshot;
+ use futures::future::{Either, select};
+ use std::io;
+ use std::sync::mpsc;
+ use std::time::Duration;
+
+ const WORKER_TIMEOUT: Duration = Duration::from_secs(5);
+
+ #[compio::test]
+ async fn cancelled_blocking_operation_keeps_its_permit_until_completion() {
+ let executor_thread = std::thread::current().id();
+ let (started, started_rx) = oneshot::channel();
+ let (release, release_rx) = mpsc::channel();
+ let mut blocked = Box::pin(run_blocking("iggy-test-blocked", move || {
+ assert_ne!(std::thread::current().id(), executor_thread);
+ let _ = started.send(());
+ release_rx
+ .recv_timeout(WORKER_TIMEOUT)
+ .map_err(io::Error::other)
+ }));
+ assert!(futures::poll!(&mut blocked).is_pending());
+ assert!(matches!(
+ select(started_rx, blocked.as_mut()).await,
+ Either::Left((Ok(()), _))
+ ));
+ drop(blocked);
+
+ let mut successor = Box::pin(run_blocking("iggy-test-successor", || Ok(())));
+ assert!(futures::poll!(&mut successor).is_pending());
+ compio::time::sleep(Duration::from_millis(1)).await;
+ assert!(futures::poll!(&mut successor).is_pending());
+ release.send(()).unwrap();
+ successor.await.unwrap();
+ }
+
+ #[compio::test]
+ async fn queued_truncate_preserves_the_open_inode_and_completes_before_sync() {
+ let directory = tempfile::tempdir().unwrap();
+ let original = directory.path().join("original");
+ let renamed = directory.path().join("renamed");
+ std::fs::write(&original, b"original contents").unwrap();
+ let file = DiskStorage
+ .open(&original, OpenMode::ReadWrite)
+ .await
+ .unwrap();
+ let (started, started_rx) = oneshot::channel();
+ let (release, release_rx) = mpsc::channel();
+ let mut blocked = Box::pin(run_blocking("iggy-test-blocked", move || {
+ let _ = started.send(());
+ release_rx
+ .recv_timeout(WORKER_TIMEOUT)
+ .map_err(io::Error::other)
+ }));
+ assert!(futures::poll!(&mut blocked).is_pending());
+ assert!(matches!(
+ select(started_rx, blocked.as_mut()).await,
+ Either::Left((Ok(()), _))
+ ));
+ let mut truncate = Box::pin(file.truncate(3));
+ assert!(futures::poll!(&mut truncate).is_pending());
+ std::fs::rename(&original, &renamed).unwrap();
+ std::fs::write(&original, b"replacement contents").unwrap();
+ release.send(()).unwrap();
+ blocked.await.unwrap();
+ truncate.await.unwrap();
+ file.sync().await.unwrap();
+
+ assert_eq!(std::fs::read(&renamed).unwrap(), b"ori");
+ assert_eq!(std::fs::read(&original).unwrap(), b"replacement contents");
+ }
+}
diff --git a/core/journal/src/lib.rs b/core/journal/src/lib.rs
index cca455a..823fce4 100644
--- a/core/journal/src/lib.rs
+++ b/core/journal/src/lib.rs
@@ -21,6 +21,10 @@
pub use server_common::Storage;
+pub mod partition_journal;
+pub use partition_journal::{DurableAppend, PartitionPrepareJournal};
+
+pub mod durable_storage;
pub mod file_storage;
pub mod local_gate;
pub mod prepare_journal;
diff --git a/core/journal/src/partition_journal.rs b/core/journal/src/partition_journal.rs
new file mode 100644
index 0000000..76d5b82
--- /dev/null
+++ b/core/journal/src/partition_journal.rs
@@ -0,0 +1,2993 @@
+// 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.
+
+#![allow(clippy::future_not_send)]
+
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
+use std::io;
+use std::path::{Path, PathBuf};
+
+use futures::TryStreamExt;
+use iggy_binary_protocol::batch::BATCH_HEADER_SIZE;
+use iggy_binary_protocol::{Command, ConsensusHeader, Operation, PrepareHeader};
+use server_common::{
+ Message,
+ iobuf::{Frozen, Owned},
+ send_messages::decode_prepare_slice,
+};
+use twox_hash::XxHash3_64;
+
+use crate::durable_storage::{DiskStorage, DurableFile, DurableStorage, OpenMode};
+
+mod segments;
+pub use segments::SegmentPosition;
+use segments::SegmentState;
+
+pub const PARTITION_WAL_BLOCK_SIZE: usize = 4096;
+pub const PARTITION_WAL_BYTES_MAX: u64 = 256 * 1024 * 1024;
+pub const PARTITION_WAL_CAPACITY_MIN: u64 = 2 * (64 * 1024 * 1024 + 4096);
+pub const PARTITION_WAL_CAPACITY_MAX: u64 = 4 * 1024 * 1024 * 1024;
+const RECORD_PREFIX: usize = 32;
+pub const PREPARE_BYTES_MAX: usize = 64 * 1024 * 1024;
+const STATE_MAGIC: &[u8; 8] = b"IGGYWAL1";
+const REFERENCE_STATE_MAGIC: &[u8; 8] = b"IGGYWAL2";
+const SEALED_STATE_MAGIC_OFFSET: usize =
+ segments::SEGMENT_STATE_OFFSET + segments::SEGMENT_STATE_BYTES;
+const INLINE_RECORD: u32 = 0;
+const SEGMENT_RECORD: u32 = 1;
+const RECORD_KIND_OFFSET: usize = 4;
+const SEGMENT_REFERENCE_BYTES: usize = 4 * size_of::<u64>();
+const REFERENCED_PREPARE_BYTES: usize = size_of::<PrepareHeader>() + SEGMENT_REFERENCE_BYTES;
+
+/// A prepare body in a segment inode retained by the WAL.
+///
+/// The caller assigns a fresh generation whenever an offset-named segment is
+/// replaced, and synchronizes the body through its writing handle before
+/// submitting this reference. Referenced bytes must remain unchanged until the
+/// WAL durably removes their operations.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct SegmentReference {
+ pub generation: u64,
+ pub start_offset: u64,
+ pub position: u64,
+ pub length: u64,
+}
+
+pub trait DurableAppend {
+ /// # Errors
+ /// Returns an error if persistence fails or the prepare does not extend the journal.
+ fn append(&mut self, prepare: Frozen<4096>) -> impl Future<Output = io::Result<()>>;
+}
+
+#[allow(clippy::struct_excessive_bools)]
+pub struct PartitionPrepareJournal<S: DurableStorage = DiskStorage> {
+ directory: PathBuf,
+ file: S::File,
+ storage: S,
+ capacity: u64,
+ state: JournalState,
+ entries: BTreeMap<u64, StoredPrepare>,
+ poisoned: bool,
+ durable_head: u64,
+ obsolete: VecDeque<PathBuf>,
+ cleanup_directory_dirty: bool,
+ recovered_prepares: Vec<Message<PrepareHeader>>,
+ segment_files: BTreeMap<(u64, u64), S::File>,
+ segment_files_dirty: bool,
+ segment_links_dirty: bool,
+ preallocate_segments: bool,
+ retained_bytes: u64,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+struct JournalState {
+ group: u64,
+ incarnation: u64,
+ generation: u64,
+ length: u64,
+ checkpoint: u64,
+ checkpoint_checksum: u128,
+ head: u64,
+ head_checksum: u128,
+ anchor_known: bool,
+ checkpoint_prepare: bool,
+ certified_log_view: Option<u32>,
+ purge_generation: u64,
+ purge_floor: u64,
+ segment_references: bool,
+ segment_storage: Option<SegmentState>,
+}
+
+#[derive(Clone, Copy)]
+struct StoredPrepare {
+ position: u64,
+ length: usize,
+ checksum: u128,
+ reference: Option<SegmentReference>,
+ next_offset: Option<u64>,
+ retained_bytes: u64,
+}
+
+impl PartitionPrepareJournal {
+ /// # Errors
+ /// Returns an error on I/O failure or invalid durable history.
+ pub async fn open(directory: &Path, group: u64, incarnation: u64) -> io::Result<Self> {
+ Self::open_with_storage(directory, group, incarnation, DiskStorage).await
+ }
+}
+
+impl<S: DurableStorage> PartitionPrepareJournal<S> {
+ /// Open and verify the durably published partition history.
+ /// The caller must first durably materialize the parent directory.
+ ///
+ /// # Errors
+ /// Returns an error on I/O failure, invalid history, or a poisoned journal.
+ pub async fn open_with_storage(
+ directory: &Path,
+ group: u64,
+ incarnation: u64,
+ storage: S,
+ ) -> io::Result<Self> {
+ Self::open_with_storage_and_capacity(
+ directory,
+ group,
+ incarnation,
+ storage,
+ PARTITION_WAL_BYTES_MAX,
+ false,
+ )
+ .await
+ }
+
+ /// Open history independently of the current admission capacity.
+ ///
+ /// # Errors
+ /// Returns an error for invalid capacity or unverifiable durable history.
+ pub async fn open_with_storage_and_capacity(
+ directory: &Path,
+ group: u64,
+ incarnation: u64,
+ storage: S,
+ capacity: u64,
+ preallocate_segments: bool,
+ ) -> io::Result<Self> {
+ if !(PARTITION_WAL_CAPACITY_MIN..=PARTITION_WAL_CAPACITY_MAX).contains(&capacity)
+ || !capacity.is_multiple_of(PARTITION_WAL_BLOCK_SIZE as u64)
+ {
+ return Err(invalid(
+ "partition WAL capacity is out of bounds or unaligned",
+ ));
+ }
+ let parent = directory
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .unwrap_or_else(|| Path::new("."));
+ if !storage.exists(parent).await? {
+ return Err(invalid(
+ "partition WAL parent must already be durably materialized",
+ ));
+ }
+ storage.create_directories(directory).await?;
+ storage.sync_directory(parent).await?;
+ let state_path = directory.join("frontier");
+ let existing = match storage.open(&state_path, OpenMode::Read).await {
+ Ok(file) => {
+ let bytes = file.read(0, PARTITION_WAL_BLOCK_SIZE).await?;
+ let state = JournalState::decode(&bytes)?;
+ if state.group != group || state.incarnation != incarnation {
+ return Err(invalid("partition WAL identity mismatch"));
+ }
+ Some(state)
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => None,
+ Err(error) => return Err(error),
+ };
+ if existing.is_none() {
+ Self::validate_unpublished_history(&storage, directory).await?;
+ }
+ let state = existing.unwrap_or_else(|| JournalState {
+ group,
+ incarnation,
+ certified_log_view: Some(0),
+ ..JournalState::default()
+ });
+ let mode = if existing.is_none() {
+ OpenMode::Create
+ } else {
+ OpenMode::ReadWrite
+ };
+ let file = storage
+ .open(&data_path(directory, state.generation), mode)
+ .await?;
+ if file.length().await? < state.length {
+ return Err(invalid("partition WAL lost acknowledged bytes"));
+ }
+ let mut journal = Self {
+ directory: directory.to_path_buf(),
+ file,
+ storage,
+ capacity,
+ state,
+ entries: BTreeMap::new(),
+ poisoned: false,
+ durable_head: state.head,
+ obsolete: VecDeque::new(),
+ cleanup_directory_dirty: false,
+ recovered_prepares: Vec::new(),
+ segment_files: BTreeMap::new(),
+ segment_files_dirty: false,
+ segment_links_dirty: false,
+ preallocate_segments,
+ retained_bytes: 0,
+ };
+ journal.recover_entries().await?;
+ // Only bytes covered by the durable frontier could have released an ack.
+ journal.file.truncate(state.length).await?;
+ journal.file.sync().await?;
+ journal.storage.sync_directory(directory).await?;
+ if existing.is_none() {
+ journal.publish(state).await?;
+ }
+ journal.discover_obsolete().await?;
+ loop {
+ let remaining = journal.obsolete.len();
+ journal.cleanup_obsolete().await;
+ if journal.obsolete.is_empty() || journal.obsolete.len() == remaining {
+ break;
+ }
+ }
+ journal.recover_segment_files().await?;
+ journal.migrate_segment_prepares().await?;
+ Ok(journal)
+ }
+
+ pub const fn certified_log_view(&self) -> Option<u32> {
+ self.state.certified_log_view
+ }
+
+ /// Publish a complete canonical view only after its required head is durable.
+ ///
+ /// # Errors
+ /// Returns an error if the expected history is absent or persistence fails.
+ pub async fn certify_log_view(&mut self, view: u32, op: u64, checksum: u128) -> io::Result<()> {
+ self.ensure_healthy()?;
+ let matches = if op == self.state.checkpoint {
+ (op == 0 && checksum == 0) || self.state.checkpoint_checksum == checksum
+ } else {
+ self.entries
+ .get(&op)
+ .is_some_and(|entry| entry.checksum == checksum)
+ };
+ if op > self.state.head || !matches {
+ return Err(invalid("view certificate does not match WAL history"));
+ }
+ self.poisoned = true;
+ self.sync_segment_files().await?;
+ self.file.sync().await?;
+ let state = JournalState {
+ certified_log_view: Some(view),
+ ..self.state
+ };
+ self.publish(state).await?;
+ self.state = state;
+ self.durable_head = state.head;
+ self.retain_active_segment_file();
+ self.poisoned = false;
+ Ok(())
+ }
+
+ #[must_use]
+ pub const fn durable_op(&self) -> u64 {
+ self.durable_head
+ }
+
+ #[must_use]
+ pub const fn head(&self) -> u64 {
+ self.state.head
+ }
+
+ #[must_use]
+ pub const fn checkpoint_op(&self) -> u64 {
+ self.state.checkpoint
+ }
+
+ #[must_use]
+ pub const fn checkpoint_checksum(&self) -> Option<u128> {
+ if self.state.anchor_known {
+ Some(self.state.checkpoint_checksum)
+ } else {
+ None
+ }
+ }
+
+ #[must_use]
+ pub const fn generation(&self) -> u64 {
+ self.state.generation
+ }
+
+ #[must_use]
+ pub const fn size_bytes(&self) -> u64 {
+ self.state.length
+ }
+
+ /// Admission includes retained body bytes, even when the WAL stores references.
+ pub const fn retained_bytes(&self) -> u64 {
+ self.retained_bytes
+ }
+
+ #[must_use]
+ pub fn contains(&self, header: &PrepareHeader) -> bool {
+ if header.op > self.durable_head {
+ return false;
+ }
+ self.entries
+ .get(&header.op)
+ .is_some_and(|entry| entry.checksum == header.checksum)
+ }
+
+ pub fn take_recovered_prepares(&mut self) -> Vec<Message<PrepareHeader>> {
+ std::mem::take(&mut self.recovered_prepares)
+ }
+
+ /// Read the retained prepares in operation order.
+ ///
+ /// # Errors
+ /// Returns an error on I/O failure, invalid history, or a poisoned journal.
+ pub async fn prepares(&self) -> io::Result<Vec<Message<PrepareHeader>>> {
+ let mut prepares = Vec::with_capacity(self.entries.len());
+ for entry in self.entries.values() {
+ let (_, length, prepare, _) = self.read_record(entry.position).await?;
+ if length != entry.length {
+ return Err(invalid("partition WAL index length mismatch"));
+ }
+ prepares.push(prepare);
+ }
+ Ok(prepares)
+ }
+
+ /// Durably replace the uncommitted suffix.
+ ///
+ /// # Errors
+ /// Returns an error on I/O failure, invalid history, or a poisoned journal.
+ pub async fn truncate_from(&mut self, from_op: u64) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if from_op <= self.state.checkpoint {
+ return Err(invalid("cannot truncate checkpointed partition operations"));
+ }
+ if from_op > self.state.head {
+ return Ok(());
+ }
+ self.state.certified_log_view = None;
+ self.rewrite(
+ self.state.checkpoint,
+ self.state.checkpoint_checksum,
+ Some(from_op),
+ None,
+ )
+ .await?;
+ self.truncate_segment_tail().await
+ }
+
+ /// Synchronize required materialized files before removing their WAL coverage.
+ /// Authorized deletions are excluded by the caller. A missing listed path
+ /// does not prove deletion was authorized and cannot permit WAL reclamation.
+ /// `synced_files` names files synchronized through their original writers;
+ /// the caller must prevent replacement until this checkpoint completes.
+ ///
+ /// # Errors
+ /// Returns an error if any file or directory barrier fails.
+ pub async fn checkpoint_files(
+ &mut self,
+ through_op: u64,
+ files: &[std::path::PathBuf],
+ directories: &[std::path::PathBuf],
+ synced_files: &BTreeSet<PathBuf>,
+ ) -> io::Result<()> {
+ futures::stream::iter(files.iter().map(Ok::<_, io::Error>))
+ .try_for_each_concurrent(16, |path| async {
+ let file = self.storage.open(path, OpenMode::Read).await?;
+ if synced_files.contains(path) {
+ Ok(())
+ } else {
+ file.sync().await
+ }
+ })
+ .await?;
+ for path in directories {
+ self.storage.sync_directory(path).await?;
+ }
+ self.checkpoint(through_op).await
+ }
+
+ /// The caller has durably materialized every operation through this point.
+ /// Reclaim a prefix already materialized durably by the caller.
+ ///
+ /// # Errors
+ /// Returns an error on I/O failure, invalid history, or a poisoned journal.
+ pub async fn checkpoint(&mut self, through_op: u64) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if through_op <= self.state.checkpoint {
+ return Ok(());
+ }
+ let checksum = self
+ .entries
+ .get(&through_op)
+ .ok_or_else(|| invalid("unknown WAL checkpoint"))?
+ .checksum;
+ self.state.anchor_known = true;
+ self.rewrite(through_op, checksum, None, None).await
+ }
+
+ /// Install an already durable replacement state, such as a completed transfer.
+ /// Replace the journal with an already durable state-transfer checkpoint.
+ ///
+ /// # Errors
+ /// Returns an error on I/O failure, invalid history, or a poisoned journal.
+ pub async fn reset(&mut self, op: u64, checksum: Option<u128>) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if self.state.segment_storage.is_some() {
+ return Err(invalid(
+ "segment reset requires the installed body boundary",
+ ));
+ }
+ self.state.anchor_known = checksum.is_some();
+ self.state.certified_log_view = None;
+ self.rewrite(op, checksum.unwrap_or(0), Some(0), None).await
+ }
+
+ /// Install the committed prepare together with its materialized checkpoint.
+ ///
+ /// # Errors
+ /// Returns an error for an invalid checkpoint prepare or a storage failure.
+ pub async fn reset_with_prepare(&mut self, prepare: Frozen<4096>) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if self.state.segment_storage.is_some() {
+ return Err(invalid(
+ "segment reset requires the installed body boundary",
+ ));
+ }
+ let header = self.validate_checkpoint_prepare(&prepare)?;
+ self.state.anchor_known = true;
+ self.state.certified_log_view = None;
+ self.rewrite(header.op, header.checksum, Some(0), Some((prepare, None)))
+ .await
+ }
+
+ fn validate_checkpoint_prepare(&self, prepare: &Frozen<4096>) -> io::Result<PrepareHeader> {
+ let header = *bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ prepare
+ .as_slice()
+ .get(..size_of::<PrepareHeader>())
+ .ok_or_else(|| invalid("short checkpoint prepare"))?,
+ )
+ .map_err(|_| invalid("invalid checkpoint prepare"))?;
+ if header.command != Command::Prepare
+ || header.group != self.state.group
+ || (header.checksum != 0 && header.identity_checksum() != header.checksum)
+ || header.size as usize != prepare.len()
+ || (header.checksum_body != 0
+ && header.checksum_body
+ != u128::from(XxHash3_64::oneshot(
+ &prepare.as_slice()[size_of::<PrepareHeader>()..],
+ )))
+ {
+ return Err(invalid("invalid checkpoint prepare identity or checksum"));
+ }
+ Ok(header)
+ }
+
+ #[must_use]
+ pub const fn purge_marker(&self) -> (u64, u64) {
+ (self.state.purge_generation, self.state.purge_floor)
+ }
+
+ /// # Errors
+ /// Returns an error if the purge marker cannot be published durably.
+ pub async fn mark_purge(&mut self, generation: u64, floor: u64) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if generation < self.state.purge_generation
+ || (generation == self.state.purge_generation && floor <= self.state.purge_floor)
+ {
+ return Ok(());
+ }
+ if floor > self.state.head
+ || (self.state.segment_storage.is_some() && floor < self.state.head)
+ {
+ return Err(invalid("purge must cover the owned segment history"));
+ }
+ self.poisoned = true;
+ self.sync_segment_files().await?;
+ self.file.sync().await?;
+ let mut state = JournalState {
+ purge_generation: generation,
+ purge_floor: floor,
+ ..self.state
+ };
+ if let Some(segments) = &mut state.segment_storage {
+ segments.reset_position(SegmentPosition::default())?;
+ }
+ self.publish(state).await?;
+ self.state = state;
+ self.durable_head = state.head;
+ // The barrier above covers every original writer before purge releases it.
+ self.segment_files.clear();
+ self.poisoned = false;
+ if self.state.segment_storage.is_some() {
+ self.migrate_segment_prepares().await?;
+ }
+ Ok(())
+ }
+
+ /// Make every buffered predecessor recoverable with one frontier publication.
+ ///
+ /// # Errors
+ /// Returns an error unless the buffered prefix and its frontier are durable.
+ pub async fn sync(&mut self) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if self.durable_head == self.state.head {
+ return Ok(());
+ }
+ self.poisoned = true;
+ self.sync_segment_files().await?;
+ self.file.sync().await?;
+ self.publish(self.state).await?;
+ self.durable_head = self.state.head;
+ self.retain_active_segment_file();
+ self.poisoned = false;
+ Ok(())
+ }
+
+ /// Append a predecessor without releasing a durable acknowledgment.
+ ///
+ /// # Errors
+ /// Returns an error on write failure, capacity exhaustion, or a history conflict.
+ pub async fn append_buffered(&mut self, prepare: Frozen<4096>) -> io::Result<()> {
+ self.append_batch_buffered(std::slice::from_ref(&prepare))
+ .await
+ }
+
+ /// Append a contiguous extent, validating every operation before allocation or I/O.
+ ///
+ /// # Errors
+ /// Returns an error on invalid history, capacity exhaustion or failed write.
+ pub async fn append_batch_buffered(&mut self, prepares: &[Frozen<4096>]) -> io::Result<()> {
+ self.append_batch_inner(prepares, None).await
+ }
+
+ /// Retain durable segment bodies and append only their headers and locations.
+ /// Non-message operations keep their inline payloads. The caller must first
+ /// synchronize every referenced body through the handle that wrote it.
+ ///
+ /// Segment names in the parent directory remain offset-based. Hard links
+ /// owned by this WAL keep referenced inodes alive across retention and purge.
+ ///
+ /// # Errors
+ /// Returns an error for invalid references, history, capacity, or storage.
+ pub async fn append_batch_referenced_buffered(
+ &mut self,
+ prepares: &[Frozen<4096>],
+ references: &[Option<SegmentReference>],
+ ) -> io::Result<()> {
+ if references.len() != prepares.len() {
+ return Err(invalid("partition WAL reference count mismatch"));
+ }
+ self.append_batch_inner(prepares, Some(references)).await
+ }
+
+ #[allow(clippy::too_many_lines)]
+ async fn append_batch_inner(
+ &mut self,
+ prepares: &[Frozen<4096>],
+ references: Option<&[Option<SegmentReference>]>,
+ ) -> io::Result<()> {
+ self.ensure_healthy()?;
+ self.cleanup_obsolete().await;
+ self.recovered_prepares.clear();
+ let mut state = self.state;
+ let mut retained_bytes = self.retained_bytes;
+ let mut records: Vec<(u64, StoredPrepare, usize)> = Vec::with_capacity(prepares.len());
+ for (index, prepare) in prepares.iter().enumerate() {
+ let header = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ prepare
+ .as_slice()
+ .get(..size_of::<PrepareHeader>())
+ .ok_or_else(|| invalid("short WAL prepare"))?,
+ )
+ .map_err(|_| invalid("invalid WAL prepare alignment"))?;
+ if self
+ .entries
+ .get(&header.op)
+ .is_some_and(|entry| entry.checksum == header.checksum)
+ || records.last().is_some_and(|(op, entry, _)| {
+ *op == header.op && entry.checksum == header.checksum
+ })
+ {
+ continue;
+ }
+ if header.op
+ != state
+ .head
+ .checked_add(1)
+ .ok_or_else(|| invalid("WAL op exhausted"))?
+ || (state.anchor_known && header.parent != state.head_checksum)
+ || header.group != state.group
+ {
+ return Err(invalid("partition WAL append does not extend its history"));
+ }
+ let (reference, next_offset) = if let Some(segments) = &mut state.segment_storage {
+ if references.is_some() {
+ return Err(invalid(
+ "externally assigned reference in owned segment storage",
+ ));
+ }
+ segments.reserve(header, prepare.as_slice())?
+ } else {
+ (references.and_then(|references| references[index]), None)
+ };
+ if let Some(reference) = reference {
+ reference.validate(header, prepare.len())?;
+ }
+ let length =
+ record_length(reference.map_or(prepare.len(), |_| REFERENCED_PREPARE_BYTES))?;
+ let body_bytes = record_length(prepare.len())? as u64;
+ if retained_bytes.saturating_add(body_bytes) > self.capacity {
+ return Err(io::Error::new(
+ io::ErrorKind::WouldBlock,
+ "partition WAL requires checkpoint",
+ ));
+ }
+ records.push((
+ header.op,
+ StoredPrepare {
+ position: state.length,
+ length,
+ checksum: header.checksum,
+ reference,
+ next_offset,
+ retained_bytes: body_bytes,
+ },
+ index,
+ ));
+ state.length += length as u64;
+ retained_bytes += body_bytes;
+ state.head = header.op;
+ state.head_checksum = header.checksum;
+ state.segment_references |= reference.is_some();
+ if state
+ .certified_log_view
+ .is_some_and(|view| header.view > view)
+ {
+ state.certified_log_view = None;
+ }
+ if !state.anchor_known {
+ state.checkpoint_checksum = header.parent;
+ state.anchor_known = true;
+ }
+ }
+ if records.is_empty() {
+ return Ok(());
+ }
+ let mut extent = Owned::zeroed(
+ usize::try_from(state.length - self.state.length)
+ .map_err(|_| invalid("WAL extent overflow"))?,
+ );
+ for (_, record, index) in &records {
+ let position = usize::try_from(record.position - self.state.length)
+ .map_err(|_| invalid("WAL extent overflow"))?;
+ encode_record_into(
+ prepares[*index].as_slice(),
+ record.reference,
+ state.generation,
+ &mut extent.as_mut_slice()[position..position + record.length],
+ )?;
+ }
+ self.poisoned = true;
+ self.write_segment_bodies(prepares, &records).await?;
+ self.retain_segment_inodes(&records).await?;
+ self.file.write_aligned(self.state.length, extent).await?;
+ for (op, record, _) in records {
+ self.entries.insert(op, record);
+ }
+ self.state = state;
+ self.retained_bytes = retained_bytes;
+ self.poisoned = false;
+ Ok(())
+ }
+
+ async fn retain_segment_inodes(
+ &self,
+ records: &[(u64, StoredPrepare, usize)],
+ ) -> io::Result<()> {
+ if self.state.segment_storage.is_some() {
+ return Ok(());
+ }
+ let mut linked = false;
+ let mut retained_segments = BTreeSet::new();
+ for (_, record, _) in records {
+ if let Some(reference) = record.reference
+ && retained_segments.insert((reference.generation, reference.start_offset))
+ {
+ let retained = reference.path(&self.directory);
+ if !self.storage.exists(&retained).await? {
+ let parent = self
+ .directory
+ .parent()
+ .ok_or_else(|| invalid("referenced segment has no partition directory"))?;
+ let source = parent.join(format!("{:020}.log", reference.start_offset));
+ self.storage.hard_link(&source, &retained).await?;
+ linked = true;
+ }
+ }
+ }
+ if linked {
+ // A visible frontier must never name an inode whose last durable
+ // directory entry a concurrent retention or purge can remove.
+ self.storage.sync_directory(&self.directory).await?;
+ }
+ Ok(())
+ }
+
+ async fn recover_entries(&mut self) -> io::Result<()> {
+ let state = self.state;
+ let mut position = 0;
+ let mut previous = state.checkpoint;
+ let mut checksum = state.checkpoint_checksum;
+ while position < state.length {
+ let (header, length, prepare, reference) = self.read_record(position).await?;
+ let next_offset = if state.segment_storage.is_some() && reference.is_some() {
+ Some(segments::batch_next_offset(prepare.as_slice())?)
+ } else {
+ None
+ };
+ self.recovered_prepares.push(prepare);
+ let checkpoint_prepare = position == 0 && state.checkpoint_prepare;
+ if checkpoint_prepare {
+ if header.op != state.checkpoint || header.checksum != state.checkpoint_checksum {
+ return Err(invalid("partition WAL checkpoint prepare mismatch"));
+ }
+ } else if header.op
+ != previous
+ .checked_add(1)
+ .ok_or_else(|| invalid("WAL op overflow"))?
+ || header.parent != checksum
+ {
+ return Err(invalid("partition WAL prepare chain is broken"));
+ }
+ self.entries.insert(
+ header.op,
+ StoredPrepare {
+ position,
+ length,
+ checksum: header.checksum,
+ reference,
+ next_offset,
+ retained_bytes: record_length(header.size as usize)? as u64,
+ },
+ );
+ previous = header.op;
+ checksum = header.checksum;
+ position += length as u64;
+ }
+ if position != state.length || previous != state.head || checksum != state.head_checksum {
+ return Err(invalid("partition WAL frontier disagrees with its data"));
+ }
+ self.retained_bytes = self
+ .entries
+ .values()
+ .map(|entry| entry.retained_bytes)
+ .sum();
+ Ok(())
+ }
+
+ async fn discover_obsolete(&mut self) -> io::Result<()> {
+ let retained = self.retained_segment_paths(&self.entries, self.state.segment_storage);
+ for entry in self.storage.entries(&self.directory).await? {
+ let Some(name) = entry.name.to_str() else {
+ continue;
+ };
+ let generation = name
+ .strip_prefix("prepares-")
+ .and_then(|name| name.strip_suffix(".wal"))
+ .and_then(|value| value.parse::<u64>().ok());
+ if !entry.directory
+ && (generation.is_some_and(|generation| generation != self.state.generation)
+ || name == "frontier.tmp"
+ || (is_retained_segment_name(name)
+ && !retained.contains(&self.directory.join(&entry.name))))
+ {
+ self.obsolete.push_back(self.directory.join(entry.name));
+ }
+ }
+ Ok(())
+ }
+
+ async fn cleanup_obsolete(&mut self) {
+ let count = self.obsolete.len().min(16);
+ for _ in 0..count {
+ let Some(path) = self.obsolete.pop_front() else {
+ break;
+ };
+ match self.storage.remove_file(&path).await {
+ Ok(()) => self.cleanup_directory_dirty = true,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ self.cleanup_directory_dirty = true;
+ }
+ Err(error) => {
+ tracing::warn!(%error, path = %path.display(), "cannot remove obsolete partition WAL generation");
+ self.obsolete.push_back(path);
+ }
+ }
+ }
+ if self.cleanup_directory_dirty {
+ match self.storage.sync_directory(&self.directory).await {
+ Ok(()) => self.cleanup_directory_dirty = false,
+ Err(error) => tracing::warn!(%error, "cannot synchronize partition WAL cleanup"),
+ }
+ }
+ }
+
+ async fn validate_unpublished_history(storage: &S, directory: &Path) -> io::Result<()> {
+ for entry in storage.entries(directory).await? {
+ if entry.directory || entry.name == "frontier.tmp" {
+ continue;
+ }
+ // An interrupted first open can leave only its empty generation-zero file.
+ // Any other history without its frontier may contain acknowledged data.
+ if entry.name != "prepares-0.wal"
+ || storage
+ .open(&directory.join(&entry.name), OpenMode::Read)
+ .await?
+ .length()
+ .await?
+ != 0
+ {
+ return Err(invalid(
+ "partition WAL history exists without its durable frontier",
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ fn ensure_healthy(&self) -> io::Result<()> {
+ if self.poisoned {
+ Err(invalid(
+ "partition WAL requires recovery after failed mutation",
+ ))
+ } else {
+ Ok(())
+ }
+ }
+
+ async fn read_record(
+ &self,
+ position: u64,
+ ) -> io::Result<(
+ PrepareHeader,
+ usize,
+ Message<PrepareHeader>,
+ Option<SegmentReference>,
+ )> {
+ let (mut buffer, frame_length, reference) = self.read_encoded_record(position).await?;
+ let length = buffer.as_slice().len();
+ if let Some(reference) = reference {
+ let payload = &buffer.as_slice()[RECORD_PREFIX..RECORD_PREFIX + frame_length];
+ let header = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &payload[..size_of::<PrepareHeader>()],
+ )
+ .map_err(|_| invalid("invalid referenced prepare header"))?;
+ let mut prepare = Owned::zeroed(header.size as usize);
+ prepare.as_mut_slice()[..size_of::<PrepareHeader>()]
+ .copy_from_slice(&payload[..size_of::<PrepareHeader>()]);
+ buffer = self
+ .storage
+ .open(&reference.path(&self.directory), OpenMode::Read)
+ .await?
+ .read_aligned_tail(reference.position, prepare, size_of::<PrepareHeader>())
+ .await?;
+ } else {
+ buffer
+ .as_mut_slice()
+ .copy_within(RECORD_PREFIX..RECORD_PREFIX + frame_length, 0);
+ buffer.truncate(frame_length);
+ }
+ let message = Message::<PrepareHeader>::try_from(buffer)
+ .map_err(|_| invalid("invalid partition WAL prepare"))?;
+ let header = *message.header();
+ if header.command != Command::Prepare
+ || header.group != self.state.group
+ || header.size as usize != message.as_slice().len()
+ {
+ return Err(invalid("partition WAL prepare identity mismatch"));
+ }
+ if reference.is_some()
+ && ((header.checksum != 0 && header.identity_checksum() != header.checksum)
+ || (header.checksum_body != 0
+ && header.checksum_body
+ != u128::from(XxHash3_64::oneshot(
+ &message.as_slice()[size_of::<PrepareHeader>()..],
+ ))))
+ {
+ return Err(invalid("referenced segment prepare checksum mismatch"));
+ }
+ if reference.is_some() && header.checksum_body == 0 {
+ // Partition prepares delegate body integrity to the canonical batch.
+ decode_prepare_slice(message.as_slice())
+ .map_err(|_| invalid("referenced segment message checksum mismatch"))?;
+ }
+ Ok((header, length, message, reference))
+ }
+
+ async fn read_encoded_record(
+ &self,
+ position: u64,
+ ) -> io::Result<(Owned<4096>, usize, Option<SegmentReference>)> {
+ let prefix = self
+ .file
+ .read_aligned(position, PARTITION_WAL_BLOCK_SIZE)
+ .await?;
+ let frame_length = u32::from_le_bytes(
+ prefix.as_slice()[..4]
+ .try_into()
+ .map_err(|_| invalid("invalid WAL prefix"))?,
+ ) as usize;
+ let length = record_length(frame_length)?;
+ if position
+ .checked_add(length as u64)
+ .is_none_or(|end| end > self.state.length)
+ {
+ return Err(invalid("partition WAL record crosses durable frontier"));
+ }
+ let mut buffer = if length == PARTITION_WAL_BLOCK_SIZE {
+ prefix
+ } else {
+ let mut bytes = Owned::zeroed(length);
+ bytes.as_mut_slice()[..PARTITION_WAL_BLOCK_SIZE].copy_from_slice(prefix.as_slice());
+ self.file
+ .read_aligned_tail(
+ position + PARTITION_WAL_BLOCK_SIZE as u64,
+ bytes,
+ PARTITION_WAL_BLOCK_SIZE,
+ )
+ .await?
+ };
+ let bytes = buffer.as_mut_slice();
+ let stored_hash = u64::from_le_bytes(
+ bytes[16..24]
+ .try_into()
+ .map_err(|_| invalid("invalid WAL checksum"))?,
+ );
+ bytes[16..24].fill(0);
+ if XxHash3_64::oneshot(&bytes[..RECORD_PREFIX + frame_length]) != stored_hash {
+ return Err(invalid("partition WAL record checksum mismatch"));
+ }
+ let generation = u64::from_le_bytes(
+ bytes[8..16]
+ .try_into()
+ .map_err(|_| invalid("invalid WAL generation"))?,
+ );
+ if generation != self.state.generation {
+ return Err(invalid("partition WAL stale record generation"));
+ }
+ let kind = u32::from_le_bytes(
+ bytes[RECORD_KIND_OFFSET..RECORD_KIND_OFFSET + size_of::<u32>()]
+ .try_into()
+ .map_err(|_| invalid("invalid WAL record kind"))?,
+ );
+ if kind != INLINE_RECORD && (kind != SEGMENT_RECORD || !self.state.segment_references) {
+ return Err(invalid("unknown partition WAL record kind"));
+ }
+ let header = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &bytes[RECORD_PREFIX..RECORD_PREFIX + size_of::<PrepareHeader>()],
+ )
+ .map_err(|_| invalid("invalid partition WAL prepare"))?;
+ header
+ .validate()
+ .map_err(|_| invalid("invalid partition WAL prepare"))?;
+ if header.group != self.state.group
+ || (kind == INLINE_RECORD && header.size as usize != frame_length)
+ {
+ return Err(invalid("partition WAL prepare identity mismatch"));
+ }
+ let reference = if kind == SEGMENT_RECORD {
+ if frame_length != REFERENCED_PREPARE_BYTES {
+ return Err(invalid("invalid segment reference record size"));
+ }
+ let reference = SegmentReference::decode(
+ &bytes[RECORD_PREFIX + size_of::<PrepareHeader>()..RECORD_PREFIX + frame_length],
+ )?;
+ reference.validate(header, header.size as usize)?;
+ Some(reference)
+ } else {
+ None
+ };
+ Ok((buffer, frame_length, reference))
+ }
+
+ async fn publish(&self, state: JournalState) -> io::Result<()> {
+ if state
+ .segment_storage
+ .is_some_and(|segments| !segments.valid())
+ {
+ return Err(invalid("invalid durable segment boundaries"));
+ }
+ let temporary = self.directory.join("frontier.tmp");
+ let mut file = self.storage.open(&temporary, OpenMode::Create).await?;
+ file.write(0, state.encode()).await?;
+ file.sync().await?;
+ self.storage
+ .rename(&temporary, &self.directory.join("frontier"))
+ .await?;
+ self.storage.sync_directory(&self.directory).await
+ }
+
+ #[allow(clippy::too_many_lines)]
+ async fn rewrite(
+ &mut self,
+ checkpoint: u64,
+ checksum: u128,
+ truncate: Option<u64>,
+ checkpoint_prepare: Option<(Frozen<4096>, Option<SegmentReference>)>,
+ ) -> io::Result<()> {
+ // A failed publication can leave a newer frontier visible on disk.
+ // Poison until reopen instead of overwriting that possibly durable state.
+ self.poisoned = true;
+ let generation = self
+ .state
+ .generation
+ .checked_add(1)
+ .ok_or_else(|| invalid("WAL generation exhausted"))?;
+ let mut file = self
+ .storage
+ .open(&data_path(&self.directory, generation), OpenMode::Create)
+ .await?;
+ let mut entries = BTreeMap::new();
+ let mut state = JournalState {
+ generation,
+ checkpoint,
+ checkpoint_checksum: checksum,
+ head: checkpoint,
+ head_checksum: checksum,
+ length: 0,
+ checkpoint_prepare: false,
+ ..self.state
+ };
+ if let Some(segments) = &mut state.segment_storage {
+ if checkpoint > self.state.checkpoint {
+ segments.checkpoint = self
+ .segment_boundary(checkpoint)
+ .ok_or_else(|| invalid("missing segment checkpoint boundary"))?;
+ }
+ if let Some(from_op) = truncate.filter(|from_op| *from_op > 0) {
+ segments.tail = self
+ .segment_boundary(from_op - 1)
+ .ok_or_else(|| invalid("missing segment rollback boundary"))?;
+ }
+ }
+ if let Some((prepare, reference)) = checkpoint_prepare {
+ let length =
+ record_length(reference.map_or(prepare.len(), |_| REFERENCED_PREPARE_BYTES))?;
+ let mut encoded = Owned::zeroed(length);
+ encode_record_into(
+ prepare.as_slice(),
+ reference,
+ generation,
+ encoded.as_mut_slice(),
+ )?;
+ let length = encoded.as_slice().len();
+ file.write_aligned(0, encoded).await?;
+ entries.insert(
+ checkpoint,
+ StoredPrepare {
+ position: 0,
+ length,
+ checksum,
+ reference,
+ next_offset: None,
+ retained_bytes: record_length(prepare.len())? as u64,
+ },
+ );
+ state.length = length as u64;
+ state.checkpoint_prepare = true;
+ }
+ let previous_entries: Vec<_> = self
+ .entries
+ .iter()
+ .map(|(&op, &entry)| (op, entry))
+ .collect();
+ for (op, entry) in previous_entries {
+ if op < checkpoint || truncate.is_some_and(|from| op >= from) {
+ continue;
+ }
+ let (record, payload_length, mut reference) =
+ self.read_encoded_record(entry.position).await?;
+ let mut next_offset = entry.next_offset;
+ // Purge removes polled data, but these operations still participate
+ // in repair. Inline their bodies before releasing whole segment inodes.
+ let purged_prepare = if state.segment_storage.is_some()
+ && reference.is_some()
+ && op <= state.purge_floor
+ {
+ let (_, _, prepare, _) = self.read_record(entry.position).await?;
+ reference = None;
+ next_offset = None;
+ Some(prepare)
+ } else {
+ None
+ };
+ let payload = purged_prepare.as_ref().map_or_else(
+ || &record.as_slice()[RECORD_PREFIX..RECORD_PREFIX + payload_length],
+ Message::as_slice,
+ );
+ let mut converted = false;
+ if reference.is_none()
+ && op > state.purge_floor
+ && let Some(segments) = &mut state.segment_storage
+ {
+ let header = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &payload[..size_of::<PrepareHeader>()],
+ )
+ .map_err(|_| invalid("invalid prepare during segment migration"))?;
+ if header.operation == Operation::SendMessages
+ && segments::decode_batch(payload)?.base_offset
+ >= segments.tail.position.next_offset
+ {
+ (reference, next_offset) = segments.reserve(header, payload)?;
+ if let Some(reference) = reference {
+ self.write_segment_body(
+ reference,
+ &Frozen::from(Owned::copy_from_slice(payload)),
+ )
+ .await?;
+ converted = true;
+ }
+ }
+ }
+ let encoded_length = if converted {
+ REFERENCED_PREPARE_BYTES
+ } else {
+ payload.len()
+ };
+ let mut encoded = Owned::zeroed(record_length(encoded_length)?);
+ if converted {
+ encode_record_into(payload, reference, generation, encoded.as_mut_slice())?;
+ } else {
+ encode_payload_into(
+ payload,
+ if reference.is_some() {
+ SEGMENT_RECORD
+ } else {
+ INLINE_RECORD
+ },
+ generation,
+ encoded.as_mut_slice(),
+ )?;
+ }
+ let length = encoded.as_slice().len();
+ file.write_aligned(state.length, encoded).await?;
+ entries.insert(
+ op,
+ StoredPrepare {
+ position: state.length,
+ length,
+ checksum: entry.checksum,
+ reference,
+ next_offset,
+ retained_bytes: entry.retained_bytes,
+ },
+ );
+ state.length += length as u64;
+ state.checkpoint_prepare |= op == checkpoint;
+ state.head = op;
+ state.head_checksum = entry.checksum;
+ }
+ self.sync_segment_files().await?;
+ file.sync().await?;
+ self.storage.sync_directory(&self.directory).await?;
+ self.publish(state).await?;
+ let obsolete = data_path(&self.directory, self.state.generation);
+ let retained = self.retained_segment_paths(&entries, state.segment_storage);
+ self.obsolete.extend(
+ self.retained_segment_paths(&self.entries, self.state.segment_storage)
+ .into_iter()
+ .filter(|path| !retained.contains(path)),
+ );
+ self.file = file;
+ self.state = state;
+ self.durable_head = state.head;
+ self.entries = entries;
+ self.retained_bytes = self
+ .entries
+ .values()
+ .map(|entry| entry.retained_bytes)
+ .sum();
+ self.retain_active_segment_file();
+ self.poisoned = false;
+ self.obsolete.push_back(obsolete);
+ self.cleanup_obsolete().await;
+ Ok(())
+ }
+}
+
+impl<S: DurableStorage> DurableAppend for PartitionPrepareJournal<S> {
+ async fn append(&mut self, prepare: Frozen<4096>) -> io::Result<()> {
+ self.append_buffered(prepare).await?;
+ self.sync().await
+ }
+}
+
+impl JournalState {
+ fn encode(self) -> Vec<u8> {
+ let mut bytes = vec![0; PARTITION_WAL_BLOCK_SIZE];
+ bytes[..8].copy_from_slice(if self.segment_references {
+ REFERENCE_STATE_MAGIC
+ } else {
+ STATE_MAGIC
+ });
+ for (offset, value) in [
+ (16, self.group),
+ (24, self.incarnation),
+ (32, self.generation),
+ (40, self.length),
+ (48, self.checkpoint),
+ (72, self.head),
+ ] {
+ bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
+ }
+ bytes[56..72].copy_from_slice(&self.checkpoint_checksum.to_le_bytes());
+ bytes[80..96].copy_from_slice(&self.head_checksum.to_le_bytes());
+ bytes[96] = u8::from(self.anchor_known);
+ bytes[97] = u8::from(self.checkpoint_prepare);
+ bytes[98] = u8::from(self.certified_log_view.is_some());
+ bytes[120..124].copy_from_slice(&self.certified_log_view.unwrap_or(0).to_le_bytes());
+ bytes[104..112].copy_from_slice(&self.purge_generation.to_le_bytes());
+ bytes[112..120].copy_from_slice(&self.purge_floor.to_le_bytes());
+ bytes[segments::SEGMENT_STATE_FLAG] = u8::from(self.segment_storage.is_some());
+ if let Some(segments) = self.segment_storage {
+ segments.encode(
+ &mut bytes[segments::SEGMENT_STATE_OFFSET
+ ..segments::SEGMENT_STATE_OFFSET + segments::SEGMENT_STATE_BYTES],
+ );
+ }
+ // Repeat the format tag inside the existing checksum range. Older
+ // readers accept these reserved bytes, so rollback stays compatible.
+ bytes.copy_within(..8, SEALED_STATE_MAGIC_OFFSET);
+ let checksum = XxHash3_64::oneshot(&bytes[16..]);
+ bytes[8..16].copy_from_slice(&checksum.to_le_bytes());
+ bytes
+ }
+
+ fn decode(bytes: &[u8]) -> io::Result<Self> {
+ if bytes.len() != PARTITION_WAL_BLOCK_SIZE
+ || (&bytes[..8] != STATE_MAGIC && &bytes[..8] != REFERENCE_STATE_MAGIC)
+ {
+ return Err(invalid("unknown partition WAL frontier format"));
+ }
+ let read_u64 = |offset| -> io::Result<u64> {
+ Ok(u64::from_le_bytes(
+ bytes[offset..offset + 8]
+ .try_into()
+ .map_err(|_| invalid("invalid WAL frontier field"))?,
+ ))
+ };
+ if read_u64(8)? != XxHash3_64::oneshot(&bytes[16..]) {
+ return Err(invalid("partition WAL frontier checksum mismatch"));
+ }
+ let sealed_magic = &bytes[SEALED_STATE_MAGIC_OFFSET..SEALED_STATE_MAGIC_OFFSET + 8];
+ if sealed_magic != [0; 8] && sealed_magic != &bytes[..8] {
+ return Err(invalid("partition WAL frontier format checksum mismatch"));
+ }
+ let state = Self {
+ segment_references: &bytes[..8] == REFERENCE_STATE_MAGIC,
+ segment_storage: match bytes[segments::SEGMENT_STATE_FLAG] {
+ 0 => None,
+ 1 if &bytes[..8] == REFERENCE_STATE_MAGIC => Some(SegmentState::decode(
+ &bytes[segments::SEGMENT_STATE_OFFSET
+ ..segments::SEGMENT_STATE_OFFSET + segments::SEGMENT_STATE_BYTES],
+ )?),
+ _ => return Err(invalid("invalid segment storage flag")),
+ },
+ group: read_u64(16)?,
+ incarnation: read_u64(24)?,
+ generation: read_u64(32)?,
+ length: read_u64(40)?,
+ checkpoint: read_u64(48)?,
+ head: read_u64(72)?,
+ anchor_known: match bytes[96] {
+ 0 => false,
+ 1 => true,
+ _ => return Err(invalid("invalid WAL anchor flag")),
+ },
+ checkpoint_prepare: match bytes[97] {
+ 0 => false,
+ 1 => true,
+ _ => return Err(invalid("invalid checkpoint prepare flag")),
+ },
+ certified_log_view: match bytes[98] {
+ 0 => None,
+ 1 => Some(u32::from_le_bytes(
+ bytes[120..124]
+ .try_into()
+ .map_err(|_| invalid("invalid certified view"))?,
+ )),
+ _ => return Err(invalid("invalid certified view flag")),
+ },
+ purge_generation: read_u64(104)?,
+ purge_floor: read_u64(112)?,
+ checkpoint_checksum: u128::from_le_bytes(
+ bytes[56..72]
+ .try_into()
+ .map_err(|_| invalid("invalid checkpoint checksum"))?,
+ ),
+ head_checksum: u128::from_le_bytes(
+ bytes[80..96]
+ .try_into()
+ .map_err(|_| invalid("invalid head checksum"))?,
+ ),
+ };
+ if state.length > PARTITION_WAL_CAPACITY_MAX
+ || !state.length.is_multiple_of(PARTITION_WAL_BLOCK_SIZE as u64)
+ || state.head < state.checkpoint
+ || (!state.anchor_known && state.head != state.checkpoint)
+ || (state.checkpoint_prepare
+ && (state.checkpoint == 0 || state.length == 0 || !state.anchor_known))
+ {
+ return Err(invalid("invalid partition WAL frontier bounds"));
+ }
+ Ok(state)
+ }
+}
+
+/// Padded size of a prepare record, including its envelope.
+///
+/// # Errors
+/// Returns an error for a frame outside the protocol size bounds.
+pub fn record_length(frame_length: usize) -> io::Result<usize> {
+ if !(size_of::<PrepareHeader>()..=PREPARE_BYTES_MAX).contains(&frame_length) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "partition prepare size {frame_length} exceeds or falls below the supported range {}..={PREPARE_BYTES_MAX} bytes",
+ size_of::<PrepareHeader>()
+ ),
+ ));
+ }
+ Ok((RECORD_PREFIX + frame_length).next_multiple_of(PARTITION_WAL_BLOCK_SIZE))
+}
+
+fn encode_record_into(
+ prepare: &[u8],
+ reference: Option<SegmentReference>,
+ generation: u64,
+ bytes: &mut [u8],
+) -> io::Result<()> {
+ if let Some(reference) = reference {
+ let mut payload = [0; REFERENCED_PREPARE_BYTES];
+ payload[..size_of::<PrepareHeader>()]
+ .copy_from_slice(&prepare[..size_of::<PrepareHeader>()]);
+ reference.encode(&mut payload[size_of::<PrepareHeader>()..]);
+ encode_payload_into(&payload, SEGMENT_RECORD, generation, bytes)
+ } else {
+ encode_payload_into(prepare, INLINE_RECORD, generation, bytes)
+ }
+}
+
+fn encode_payload_into(
+ payload: &[u8],
+ kind: u32,
+ generation: u64,
+ bytes: &mut [u8],
+) -> io::Result<()> {
+ let length = u32::try_from(payload.len()).map_err(|_| invalid("oversized prepare"))?;
+ bytes[..4].copy_from_slice(&length.to_le_bytes());
+ bytes[RECORD_KIND_OFFSET..RECORD_KIND_OFFSET + size_of::<u32>()]
+ .copy_from_slice(&kind.to_le_bytes());
+ bytes[8..16].copy_from_slice(&generation.to_le_bytes());
+ bytes[RECORD_PREFIX..RECORD_PREFIX + payload.len()].copy_from_slice(payload);
+ let checksum = XxHash3_64::oneshot(&bytes[..RECORD_PREFIX + payload.len()]);
+ bytes[16..24].copy_from_slice(&checksum.to_le_bytes());
+ Ok(())
+}
+
+impl SegmentReference {
+ fn validate(self, header: &PrepareHeader, frame_length: usize) -> io::Result<()> {
+ if header.command != Command::Prepare
+ || header.operation != Operation::SendMessages
+ || header.size as usize != frame_length
+ || !(size_of::<PrepareHeader>() + BATCH_HEADER_SIZE..=PREPARE_BYTES_MAX)
+ .contains(&frame_length)
+ || self.length != (frame_length - size_of::<PrepareHeader>()) as u64
+ || self.position.checked_add(self.length).is_none()
+ {
+ return Err(invalid("invalid partition WAL segment reference"));
+ }
+ Ok(())
+ }
+
+ fn path(self, directory: &Path) -> PathBuf {
+ segment_path(directory, self.generation, self.start_offset)
+ }
+
+ fn encode(self, bytes: &mut [u8]) {
+ for (field, value) in bytes
+ .as_chunks_mut::<{ size_of::<u64>() }>()
+ .0
+ .iter_mut()
+ .zip([
+ self.generation,
+ self.start_offset,
+ self.position,
+ self.length,
+ ])
+ {
+ field.copy_from_slice(&value.to_le_bytes());
+ }
+ }
+
+ fn decode(bytes: &[u8]) -> io::Result<Self> {
+ if bytes.len() != SEGMENT_REFERENCE_BYTES {
+ return Err(invalid("invalid partition WAL segment reference size"));
+ }
+ let mut fields = [0; SEGMENT_REFERENCE_BYTES / size_of::<u64>()];
+ for (field, bytes) in fields
+ .iter_mut()
+ .zip(bytes.as_chunks::<{ size_of::<u64>() }>().0)
+ {
+ *field = u64::from_le_bytes(*bytes);
+ }
+ let [generation, start_offset, position, length] = fields;
+ Ok(Self {
+ generation,
+ start_offset,
+ position,
+ length,
+ })
+ }
+}
+
+fn segment_path(directory: &Path, generation: u64, start_offset: u64) -> PathBuf {
+ directory.join(format!("segment-{generation}-{start_offset}.log"))
+}
+
+fn referenced_segments(
+ entries: &BTreeMap<u64, StoredPrepare>,
+ directory: &Path,
+) -> BTreeSet<PathBuf> {
+ entries
+ .values()
+ .filter_map(|entry| entry.reference.map(|reference| reference.path(directory)))
+ .collect()
+}
+
+fn is_retained_segment_name(name: &str) -> bool {
+ name.strip_prefix("segment-")
+ .and_then(|name| name.strip_suffix(".log"))
+ .and_then(|name| name.split_once('-'))
+ .is_some_and(|(generation, start_offset)| {
+ generation.parse::<u64>().is_ok() && start_offset.parse::<u64>().is_ok()
+ })
+}
+
+fn data_path(directory: &Path, generation: u64) -> PathBuf {
+ directory.join(format!("prepares-{generation}.wal"))
+}
+
+fn invalid(message: &'static str) -> io::Error {
+ io::Error::new(io::ErrorKind::InvalidData, message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use compio::fs::File;
+ use compio::io::AsyncWriteAtExt;
+ use iggy_binary_protocol::Operation;
+ use iggy_binary_protocol::batch::BatchHeader;
+ use tempfile::tempdir;
+
+ #[cfg(target_os = "linux")]
+ use std::os::unix::fs::MetadataExt;
+
+ const REFERENCE_TEST_BODY_BYTES: usize = 1024 * 1024;
+ #[cfg(target_os = "linux")]
+ const FILE_BLOCK_BYTES: u64 = 512;
+
+ #[cfg(target_os = "linux")]
+ fn supports_preallocation(directory: &Path, length: u64) -> bool {
+ let probe = tempfile::tempfile_in(directory).unwrap();
+ match nix::fcntl::fallocate(
+ &probe,
+ nix::fcntl::FallocateFlags::FALLOC_FL_KEEP_SIZE,
+ 0,
+ i64::try_from(length).unwrap(),
+ ) {
+ Ok(()) => true,
+ Err(nix::errno::Errno::EOPNOTSUPP | nix::errno::Errno::ENOSYS) => false,
+ Err(error) => panic!("preallocation probe failed: {error}"),
+ }
+ }
+
+ #[compio::test]
+ async fn referenced_bodies_survive_retention_and_checkpoint_without_wal_copies() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = sized_prepare(1, 0, size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES);
+ let second = sized_prepare(
+ 2,
+ first.header().checksum,
+ size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES,
+ );
+ let first_reference = write_segment(partition.path(), 0, 0, &first).await;
+ let second_reference = write_segment(partition.path(), 0, 1, &second).await;
+ journal
+ .append_batch_referenced_buffered(
+ &[first.clone().into_frozen(), second.clone().into_frozen()],
+ &[Some(first_reference), Some(second_reference)],
+ )
+ .await
+ .unwrap();
+ assert!(!journal.contains(second.header()));
+ journal.sync().await.unwrap();
+ assert_eq!(journal.size_bytes(), 2 * PARTITION_WAL_BLOCK_SIZE as u64);
+ for offset in [0, 1] {
+ DiskStorage
+ .remove_file(&partition.path().join(format!("{offset:020}.log")))
+ .await
+ .unwrap();
+ }
+ DiskStorage.sync_directory(partition.path()).await.unwrap();
+ drop(journal);
+
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), second.as_slice());
+ journal.checkpoint(2).await.unwrap();
+ assert_eq!(journal.size_bytes(), PARTITION_WAL_BLOCK_SIZE as u64);
+ assert!(!first_reference.path(&directory).exists());
+ assert!(second_reference.path(&directory).exists());
+ drop(journal);
+
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered.len(), 1);
+ assert_eq!(recovered[0].as_slice(), second.as_slice());
+ }
+
+ #[compio::test]
+ async fn truncating_a_reference_keeps_the_shared_segment_for_its_retained_prefix() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = sized_prepare(1, 0, size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES);
+ let second = sized_prepare(
+ 2,
+ first.header().checksum,
+ size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES,
+ );
+ let first_reference = write_segment(partition.path(), 0, 0, &first).await;
+ let mut segment = DiskStorage
+ .open(
+ &partition.path().join("00000000000000000000.log"),
+ OpenMode::ReadWrite,
+ )
+ .await
+ .unwrap();
+ DurableFile::write(
+ &mut segment,
+ first_reference.length,
+ second.as_slice()[size_of::<PrepareHeader>()..].to_vec(),
+ )
+ .await
+ .unwrap();
+ DurableFile::sync(&segment).await.unwrap();
+ let second_reference = SegmentReference {
+ position: first_reference.length,
+ ..first_reference
+ };
+ journal
+ .append_batch_referenced_buffered(
+ &[first.clone().into_frozen(), second.into_frozen()],
+ &[Some(first_reference), Some(second_reference)],
+ )
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ journal.truncate_from(2).await.unwrap();
+ assert!(first_reference.path(&directory).exists());
+ DurableFile::truncate(&segment, first_reference.length)
+ .await
+ .unwrap();
+ let replacement = sized_prepare(
+ 2,
+ first.header().checksum,
+ size_of::<PrepareHeader>() + BATCH_HEADER_SIZE,
+ );
+ DurableFile::write(
+ &mut segment,
+ first_reference.length,
+ replacement.as_slice()[size_of::<PrepareHeader>()..].to_vec(),
+ )
+ .await
+ .unwrap();
+ DurableFile::sync(&segment).await.unwrap();
+ journal
+ .append_batch_referenced_buffered(
+ &[replacement.clone().into_frozen()],
+ &[Some(SegmentReference {
+ length: BATCH_HEADER_SIZE as u64,
+ ..second_reference
+ })],
+ )
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), replacement.as_slice());
+ }
+
+ #[compio::test]
+ async fn references_coexist_with_legacy_records_and_survive_purge_name_reuse() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let legacy = prepare(1, 0);
+ journal.append(legacy.clone().into_frozen()).await.unwrap();
+ assert_eq!(
+ &std::fs::read(directory.join("frontier")).unwrap()[..8],
+ STATE_MAGIC
+ );
+ let first = sized_prepare(
+ 2,
+ legacy.header().checksum,
+ size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES,
+ );
+ let first_reference = write_segment(partition.path(), 0, 0, &first).await;
+ journal
+ .append_batch_referenced_buffered(
+ &[first.clone().into_frozen()],
+ &[Some(first_reference)],
+ )
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ assert_eq!(
+ &std::fs::read(directory.join("frontier")).unwrap()[..8],
+ REFERENCE_STATE_MAGIC
+ );
+ journal.mark_purge(1, 2).await.unwrap();
+ DiskStorage
+ .remove_file(&partition.path().join("00000000000000000000.log"))
+ .await
+ .unwrap();
+ let second = sized_prepare(
+ 3,
+ first.header().checksum,
+ size_of::<PrepareHeader>() + BATCH_HEADER_SIZE,
+ );
+ let second_reference = write_segment(partition.path(), 1, 0, &second).await;
+ journal
+ .append_batch_referenced_buffered(
+ &[second.clone().into_frozen()],
+ &[Some(second_reference)],
+ )
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ drop(journal);
+
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), legacy.as_slice());
+ assert_eq!(recovered[1].as_slice(), first.as_slice());
+ assert_eq!(recovered[2].as_slice(), second.as_slice());
+ assert_eq!(journal.purge_marker(), (1, 2));
+ journal.truncate_from(3).await.unwrap();
+ assert!(first_reference.path(&directory).exists());
+ assert!(!second_reference.path(&directory).exists());
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(
+ journal.prepares().await.unwrap()[1].as_slice(),
+ first.as_slice()
+ );
+ }
+
+ #[compio::test]
+ async fn recovery_sweeps_unpublished_references_and_keeps_the_durable_prefix() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ let second = sized_prepare(
+ 2,
+ first.header().checksum,
+ size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES,
+ );
+ let reference = write_segment(partition.path(), 0, 0, &second).await;
+ journal
+ .append_batch_referenced_buffered(&[second.into_frozen()], &[Some(reference)])
+ .await
+ .unwrap();
+ assert!(reference.path(&directory).exists());
+ drop(journal);
+
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.head(), 1);
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ first.as_slice()
+ );
+ assert!(!reference.path(&directory).exists());
+ }
+
+ #[compio::test]
+ async fn recovery_refuses_missing_torn_or_corrupt_referenced_bodies_without_rewriting_evidence()
+ {
+ for damage in ["missing", "torn", "corrupt"] {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = sized_prepare(1, 0, size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES);
+ let reference = write_segment(partition.path(), 0, 0, &first).await;
+ journal
+ .append_batch_referenced_buffered(&[first.into_frozen()], &[Some(reference)])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ drop(journal);
+ let wal = std::fs::read(data_path(&directory, 0)).unwrap();
+ let frontier = std::fs::read(directory.join("frontier")).unwrap();
+ let retained = reference.path(&directory);
+ if damage == "missing" {
+ DiskStorage.remove_file(&retained).await.unwrap();
+ } else {
+ let mut file = DiskStorage
+ .open(&retained, OpenMode::ReadWrite)
+ .await
+ .unwrap();
+ if damage == "torn" {
+ DurableFile::truncate(&file, reference.length - 1)
+ .await
+ .unwrap();
+ } else {
+ DurableFile::write(&mut file, reference.length - 1, vec![0])
+ .await
+ .unwrap();
+ }
+ DurableFile::sync(&file).await.unwrap();
+ }
+ assert!(
+ PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .is_err(),
+ "{damage}"
+ );
+ assert_eq!(
+ std::fs::read(data_path(&directory, 0)).unwrap(),
+ wal,
+ "{damage}"
+ );
+ assert_eq!(
+ std::fs::read(directory.join("frontier")).unwrap(),
+ frontier,
+ "{damage}"
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn invalid_references_are_rejected_before_modifying_history() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = sized_prepare(1, 0, size_of::<PrepareHeader>() + REFERENCE_TEST_BODY_BYTES)
+ .into_frozen();
+ let reference = SegmentReference {
+ generation: 0,
+ start_offset: 0,
+ position: 0,
+ length: REFERENCE_TEST_BODY_BYTES as u64,
+ };
+ for invalid_reference in [
+ SegmentReference {
+ length: reference.length - 1,
+ ..reference
+ },
+ SegmentReference {
+ position: u64::MAX,
+ ..reference
+ },
+ ] {
+ assert!(
+ journal
+ .append_batch_referenced_buffered(
+ std::slice::from_ref(&first),
+ &[Some(invalid_reference)]
+ )
+ .await
+ .is_err()
+ );
+ }
+ assert!(
+ journal
+ .append_batch_referenced_buffered(&[first], &[])
+ .await
+ .is_err()
+ );
+ assert_eq!(journal.head(), 0);
+ assert_eq!(journal.size_bytes(), 0);
+ assert!(!reference.path(&directory).exists());
+ assert!(!journal.poisoned);
+ }
+
+ async fn write_segment(
+ partition: &Path,
+ generation: u64,
+ start_offset: u64,
+ prepare: &Message<PrepareHeader>,
+ ) -> SegmentReference {
+ let body = &prepare.as_slice()[size_of::<PrepareHeader>()..];
+ let path = partition.join(format!("{start_offset:020}.log"));
+ let mut file = DiskStorage.open(&path, OpenMode::Create).await.unwrap();
+ DurableFile::write(&mut file, 0, body.to_vec())
+ .await
+ .unwrap();
+ DurableFile::sync(&file).await.unwrap();
+ DiskStorage.sync_directory(partition).await.unwrap();
+ SegmentReference {
+ generation,
+ start_offset,
+ position: 0,
+ length: body.len() as u64,
+ }
+ }
+
+ #[compio::test]
+ async fn durable_frontier_recovers_only_covered_prepares() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ journal
+ .append_buffered(second.clone().into_frozen())
+ .await
+ .unwrap();
+ assert!(journal.contains(first.header()));
+ assert!(!journal.contains(second.header()));
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.head(), 1);
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ first.as_slice()
+ );
+ assert_eq!(
+ journal.file.metadata().await.unwrap().len(),
+ journal.size_bytes()
+ );
+ }
+
+ #[compio::test]
+ async fn durable_append_covers_buffered_predecessors() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ journal
+ .append_buffered(first.clone().into_frozen())
+ .await
+ .unwrap();
+ journal.append(second.clone().into_frozen()).await.unwrap();
+ assert!(journal.contains(first.header()));
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let entries = journal.prepares().await.unwrap();
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[1].as_slice(), second.as_slice());
+ }
+
+ #[compio::test]
+ async fn zeroed_interior_record_is_not_an_unwritten_tail() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.append(second.into_frozen()).await.unwrap();
+ let length = journal.size_bytes();
+ let (result, _) = journal
+ .file
+ .write_all_at(vec![0; PARTITION_WAL_BLOCK_SIZE], 0)
+ .await
+ .into();
+ result.unwrap();
+ journal.file.sync_data().await.unwrap();
+ drop(journal);
+ assert!(
+ PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .is_err()
+ );
+ assert_eq!(
+ File::open(data_path(directory.path(), 0))
+ .await
+ .unwrap()
+ .metadata()
+ .await
+ .unwrap()
+ .len(),
+ length
+ );
+ }
+
+ #[compio::test]
+ async fn truncate_and_checkpoint_preserve_the_recoverable_history() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ let third = prepare(3, second.header().checksum);
+ for entry in [&first, &second, &third] {
+ journal.append(entry.clone().into_frozen()).await.unwrap();
+ }
+ journal.truncate_from(3).await.unwrap();
+ journal.checkpoint(1).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.checkpoint_op(), 1);
+ assert_eq!(journal.head(), 2);
+ assert_eq!(journal.prepares().await.unwrap().len(), 2);
+ journal.append(third.into_frozen()).await.unwrap();
+ assert_eq!(journal.head(), 3);
+ assert!(journal.truncate_from(1).await.is_err());
+ }
+
+ #[compio::test]
+ async fn missing_durable_tail_and_wrong_incarnation_are_rejected() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ journal.append(prepare(1, 0).into_frozen()).await.unwrap();
+ assert!(
+ PartitionPrepareJournal::open(directory.path(), 42, 8)
+ .await
+ .is_err()
+ );
+ journal.file.set_len(0).await.unwrap();
+ journal.file.sync_data().await.unwrap();
+ drop(journal);
+ assert!(
+ PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .is_err()
+ );
+ }
+
+ #[compio::test]
+ async fn same_generation_purge_retry_persists_the_larger_floor() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.mark_purge(9, 1).await.unwrap();
+ journal.append(second.into_frozen()).await.unwrap();
+ journal.mark_purge(9, 2).await.unwrap();
+ journal.mark_purge(9, 1).await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.purge_marker(), (9, 2));
+ }
+
+ #[compio::test]
+ async fn view_certificate_requires_complete_matching_history() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let checksum = first.header().checksum;
+ assert!(journal.certify_log_view(2, 1, checksum).await.is_err());
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.certify_log_view(2, 1, checksum).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.certified_log_view(), Some(2));
+ journal.truncate_from(1).await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.certified_log_view(), None);
+ }
+
+ #[compio::test]
+ async fn transferred_checkpoint_retains_its_prepare_after_restart() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let checkpoint = prepare(7, 1234);
+ let checksum = checkpoint.header().checksum;
+ let expected = checkpoint.as_slice().to_vec();
+ journal
+ .reset_with_prepare(checkpoint.into_frozen())
+ .await
+ .unwrap();
+ journal.certify_log_view(2, 7, checksum).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.checkpoint_op(), 7);
+ assert_eq!(journal.certified_log_view(), Some(2));
+ let prepares = journal.take_recovered_prepares();
+ assert_eq!(prepares.len(), 1);
+ assert_eq!(prepares[0].as_slice(), expected);
+ journal
+ .append(prepare(8, checksum).into_frozen())
+ .await
+ .unwrap();
+ }
+
+ #[compio::test]
+ async fn purge_marker_does_not_promote_the_uncommitted_tail_to_checkpoint() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ journal.append(prepare(1, 0).into_frozen()).await.unwrap();
+ journal.mark_purge(9, 1).await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.purge_marker(), (9, 1));
+ assert_eq!(journal.checkpoint_op(), 0);
+ assert_eq!(journal.prepares().await.unwrap().len(), 1);
+ }
+
+ #[compio::test]
+ async fn transferred_checkpoint_binds_the_next_accepted_parent() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ journal.reset(7, None).await.unwrap();
+ let next = prepare(8, 1234);
+ journal.append(next.clone().into_frozen()).await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.checkpoint_op(), 7);
+ assert_eq!(journal.head(), 8);
+ assert!(journal.contains(next.header()));
+ }
+
+ #[compio::test]
+ async fn reducing_capacity_recovers_existing_history_and_backpressures_new_appends() {
+ let directory = tempdir().unwrap();
+ let mut journal = PartitionPrepareJournal::open(directory.path(), 42, 7)
+ .await
+ .unwrap();
+ let mut buffer = Owned::<4096>::zeroed(PREPARE_BYTES_MAX);
+ let template = prepare(1, 0);
+ buffer.as_mut_slice()[..size_of::<PrepareHeader>()]
+ .copy_from_slice(&template.as_slice()[..size_of::<PrepareHeader>()]);
+ let checksum_body = XxHash3_64::oneshot(&buffer.as_slice()[size_of::<PrepareHeader>()..]);
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+ &mut buffer.as_mut_slice()[..size_of::<PrepareHeader>()],
+ );
+ header.size = u32::try_from(PREPARE_BYTES_MAX).unwrap();
+ header.checksum_body = u128::from(checksum_body);
+ header.checksum = header.identity_checksum();
+ let first = Message::<PrepareHeader>::try_from(buffer).unwrap();
+ let second = sized_prepare(2, first.header().checksum, PREPARE_BYTES_MAX);
+ let third = prepare(3, second.header().checksum);
+ journal.append_buffered(first.into_frozen()).await.unwrap();
+ let fourth = prepare(4, third.header().checksum);
+ journal.append_buffered(second.into_frozen()).await.unwrap();
+ journal.append(third.into_frozen()).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open_with_storage_and_capacity(
+ directory.path(),
+ 42,
+ 7,
+ DiskStorage,
+ PARTITION_WAL_CAPACITY_MIN,
+ false,
+ )
+ .await
+ .unwrap();
+ assert_eq!(journal.head(), 3);
+ assert!(journal.size_bytes() > PARTITION_WAL_CAPACITY_MIN);
+ assert!(journal.append(fourth.clone().into_frozen()).await.is_err());
+ journal.checkpoint(3).await.unwrap();
+ journal.append(fourth.into_frozen()).await.unwrap();
+ assert_eq!(journal.head(), 4);
+ }
+
+ #[test]
+ fn record_padding_and_state_checksums_cover_the_format() {
+ assert_eq!(record_length(256).unwrap(), 4096);
+ assert_eq!(record_length(4096).unwrap(), 8192);
+ assert!(record_length(PREPARE_BYTES_MAX + 1).is_err());
+ let state = JournalState {
+ group: 42,
+ incarnation: 7,
+ ..JournalState::default()
+ };
+ let mut bytes = state.encode();
+ assert_eq!(JournalState::decode(&bytes).unwrap(), state);
+ bytes[24] ^= 1;
+ assert!(JournalState::decode(&bytes).is_err());
+ }
+
+ #[compio::test]
+ async fn purge_releases_segment_inodes_after_preserving_repair_bodies_inline() {
+ const BODY_BYTES: usize = 8192;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), BODY_BYTES as u64)
+ .await
+ .unwrap();
+ let mut parent = 0;
+ let mut prepares = Vec::new();
+ let mut references = Vec::new();
+ for op in 1..=3 {
+ let prepare = segment_prepare(op, parent, op - 1, BODY_BYTES);
+ parent = prepare.header().checksum;
+ journal.append(prepare.clone().into_frozen()).await.unwrap();
+ references.push(journal.segment_reference(prepare.header()).unwrap());
+ prepares.push(prepare);
+ }
+ let retained_bytes = journal.retained_bytes();
+ journal.mark_purge(1, 3).await.unwrap();
+ assert_eq!(
+ journal.checkpoint_op(),
+ 0,
+ "purge must not fabricate a committed frontier"
+ );
+ assert_eq!(journal.retained_bytes(), retained_bytes);
+ for reference in references {
+ assert!(!reference.path(&directory).exists());
+ std::fs::remove_file(
+ partition
+ .path()
+ .join(format!("{:020}.log", reference.start_offset)),
+ )
+ .unwrap();
+ }
+ DiskStorage.sync_directory(partition.path()).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ for (actual, expected) in journal.prepares().await.unwrap().iter().zip(&prepares) {
+ assert_eq!(actual.as_slice(), expected.as_slice());
+ assert!(journal.segment_reference(actual.header()).is_none());
+ }
+ journal.checkpoint(3).await.unwrap();
+ assert_eq!(
+ journal.size_bytes(),
+ record_length(prepares[2].as_slice().len()).unwrap() as u64
+ );
+ }
+
+ #[compio::test]
+ async fn invalid_installed_empty_boundary_preserves_public_bytes_and_can_be_retried() {
+ const BODY_BYTES: usize = 4096;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let prepare = segment_prepare(1, 0, 0, BODY_BYTES);
+ write_segment(partition.path(), 0, 0, &prepare).await;
+ let public = partition.path().join("00000000000000000000.log");
+ let body = std::fs::read(&public).unwrap();
+ let frontier = std::fs::read(directory.join("frontier")).unwrap();
+ assert!(
+ journal
+ .reset_with_segment_checkpoint(
+ 1,
+ Some(prepare.header().checksum),
+ Some(prepare.clone().into_frozen()),
+ SegmentPosition::default(),
+ BODY_BYTES as u64,
+ )
+ .await
+ .is_err()
+ );
+ assert!(!journal.poisoned);
+ assert_eq!(journal.head(), 0);
+ assert_eq!(std::fs::read(&public).unwrap(), body);
+ assert_eq!(std::fs::read(directory.join("frontier")).unwrap(), frontier);
+ let initial = SegmentPosition {
+ start_offset: 0,
+ length: BODY_BYTES as u64,
+ next_offset: 1,
+ };
+ journal
+ .reset_with_segment_checkpoint(
+ 1,
+ Some(prepare.header().checksum),
+ Some(prepare.into_frozen()),
+ initial,
+ BODY_BYTES as u64,
+ )
+ .await
+ .unwrap();
+ assert_eq!(journal.segment_checkpoint(), Some(initial));
+ assert_eq!(std::fs::read(public).unwrap(), body);
+ }
+
+ #[compio::test]
+ async fn inline_migration_refuses_offset_gaps_before_modifying_history() {
+ const BODY_BYTES: usize = 4096;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 2, BODY_BYTES);
+ journal
+ .append_batch_buffered(&[first.into_frozen(), second.into_frozen()])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ let frontier = std::fs::read(directory.join("frontier")).unwrap();
+ let wal = std::fs::read(data_path(&directory, journal.state.generation)).unwrap();
+ assert!(
+ journal
+ .enable_segment_storage(SegmentPosition::default(), BODY_BYTES as u64)
+ .await
+ .is_err()
+ );
+ assert!(!journal.poisoned);
+ assert!(journal.segment_checkpoint().is_none());
+ assert_eq!(std::fs::read(directory.join("frontier")).unwrap(), frontier);
+ assert_eq!(
+ std::fs::read(data_path(&directory, journal.state.generation)).unwrap(),
+ wal
+ );
+ assert!(!partition.path().join("00000000000000000000.log").exists());
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.prepares().await.unwrap().len(), 2);
+ }
+
+ #[compio::test]
+ async fn non_message_prepares_do_not_rewrite_the_owned_wal_on_reopen() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), PARTITION_WAL_BLOCK_SIZE as u64)
+ .await
+ .unwrap();
+ let offset = prepare(1, 0).transmute_header(|original, header: &mut PrepareHeader| {
+ *header = original;
+ header.operation = Operation::StoreConsumerOffset;
+ header.checksum = header.identity_checksum();
+ });
+ journal.append(offset.clone().into_frozen()).await.unwrap();
+ let generation = journal.state.generation;
+ let frontier = std::fs::read(directory.join("frontier")).unwrap();
+ for _ in 0..2 {
+ drop(journal);
+ journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.state.generation, generation);
+ assert_eq!(std::fs::read(directory.join("frontier")).unwrap(), frontier);
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ offset.as_slice()
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn owned_purge_refuses_a_partial_floor_and_preserves_recoverable_history() {
+ const BODY_BYTES: usize = 4096;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), BODY_BYTES as u64)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ journal
+ .append_batch_buffered(&[first.into_frozen(), second.into_frozen()])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ let frontier = std::fs::read(directory.join("frontier")).unwrap();
+ assert!(journal.mark_purge(1, 1).await.is_err());
+ assert!(!journal.poisoned);
+ assert_eq!(std::fs::read(directory.join("frontier")).unwrap(), frontier);
+ let mut invalid_state = journal.state;
+ invalid_state
+ .segment_storage
+ .as_mut()
+ .unwrap()
+ .checkpoint
+ .position
+ .next_offset = 3;
+ assert!(journal.publish(invalid_state).await.is_err());
+ assert_eq!(std::fs::read(directory.join("frontier")).unwrap(), frontier);
+ journal.mark_purge(1, 2).await.unwrap();
+ journal.checkpoint(2).await.unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.purge_marker(), (1, 2));
+ assert_eq!(
+ journal.segment_checkpoint(),
+ Some(SegmentPosition::default())
+ );
+ assert_eq!(journal.prepares().await.unwrap().len(), 1);
+ }
+
+ #[compio::test]
+ async fn malformed_durable_segment_boundaries_are_refused_on_reopen() {
+ for invalid in [
+ "zero size",
+ "tail generation",
+ "checkpoint generation",
+ "tail ordering",
+ "empty tail bytes",
+ "empty tail offsets",
+ "checkpoint ordering",
+ "empty checkpoint bytes",
+ "empty checkpoint offsets",
+ "rewound tail",
+ ] {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), PARTITION_WAL_BLOCK_SIZE as u64)
+ .await
+ .unwrap();
+ let mut state = journal.state;
+ let segments = state.segment_storage.as_mut().unwrap();
+ match invalid {
+ "zero size" => segments.max_size = 0,
+ "tail generation" => segments.tail.generation = segments.next_generation,
+ "checkpoint generation" => {
+ segments.checkpoint.generation = segments.next_generation;
+ }
+ "tail ordering" => segments.tail.position.start_offset = 1,
+ "empty tail bytes" => segments.tail.position.next_offset = 1,
+ "empty tail offsets" => segments.tail.position.length = 1,
+ "checkpoint ordering" => segments.checkpoint.position.start_offset = 1,
+ "empty checkpoint bytes" => segments.checkpoint.position.next_offset = 1,
+ "empty checkpoint offsets" => segments.checkpoint.position.length = 1,
+ "rewound tail" => {
+ segments.checkpoint.position = SegmentPosition {
+ length: 1,
+ next_offset: 1,
+ ..Default::default()
+ }
+ }
+ _ => unreachable!(),
+ }
+ let encoded = state.encode();
+ assert!(JournalState::decode(&encoded).is_err(), "{invalid}");
+ std::fs::write(directory.join("frontier"), encoded).unwrap();
+ drop(journal);
+ let error = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .err()
+ .expect(invalid);
+ assert_eq!(
+ error.kind(),
+ io::ErrorKind::InvalidData,
+ "{invalid}: {error}"
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn a_segment_candidate_below_the_checkpoint_cannot_rewind_its_boundary() {
+ const BODY_BYTES: usize = 4096;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (4 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.append(second.into_frozen()).await.unwrap();
+ journal.checkpoint(1).await.unwrap();
+ let checkpoint = journal.state.segment_storage.unwrap().checkpoint;
+ assert_eq!(journal.segment_boundary(2).unwrap().position.next_offset, 2);
+ journal.entries.get_mut(&2).unwrap().next_offset = Some(0);
+ assert_eq!(journal.segment_boundary(2), Some(checkpoint));
+ }
+
+ #[compio::test]
+ async fn oversized_durable_segment_layout_is_refused_before_recovery_allocation() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), PARTITION_WAL_BLOCK_SIZE as u64)
+ .await
+ .unwrap();
+ let mut state = journal.state;
+ state.segment_storage.as_mut().unwrap().max_size = iggy_common::MAX_TOPIC_SEGMENT_SIZE + 1;
+ let encoded = state.encode();
+ assert!(JournalState::decode(&encoded).is_err());
+ std::fs::write(directory.join("frontier"), encoded).unwrap();
+ drop(journal);
+ assert!(
+ PartitionPrepareJournal::open_with_storage_and_capacity(
+ &directory,
+ 42,
+ 7,
+ DiskStorage,
+ PARTITION_WAL_BYTES_MAX,
+ true,
+ )
+ .await
+ .is_err()
+ );
+ assert!(!partition.path().join("00000000000000000000.log").exists());
+ }
+
+ #[test]
+ fn frontier_magic_is_protected_with_legacy_reader_and_writer_compatibility() {
+ for segment_references in [false, true] {
+ let state = JournalState {
+ group: 42,
+ incarnation: 7,
+ segment_references,
+ ..JournalState::default()
+ };
+ let mut encoded = state.encode();
+ assert_eq!(JournalState::decode(&encoded).unwrap(), state);
+ // The old reader hashes the same payload and ignores reserved bytes.
+ assert_eq!(
+ u64::from_le_bytes(encoded[8..16].try_into().unwrap()),
+ XxHash3_64::oneshot(&encoded[16..])
+ );
+ encoded[..8].copy_from_slice(if segment_references {
+ STATE_MAGIC
+ } else {
+ REFERENCE_STATE_MAGIC
+ });
+ assert!(JournalState::decode(&encoded).is_err());
+ let mut legacy = state.encode();
+ legacy[SEALED_STATE_MAGIC_OFFSET..SEALED_STATE_MAGIC_OFFSET + 8].fill(0);
+ let checksum = XxHash3_64::oneshot(&legacy[16..]);
+ legacy[8..16].copy_from_slice(&checksum.to_le_bytes());
+ assert_eq!(JournalState::decode(&legacy).unwrap(), state);
+ }
+ }
+
+ #[compio::test]
+ async fn owned_segments_rollback_physical_tails_without_advancing_the_checkpoint() {
+ const BODY_BYTES: usize = 8192;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (2 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ let third = segment_prepare(3, second.header().checksum, 2, BODY_BYTES);
+ journal
+ .append_batch_buffered(&[
+ first.clone().into_frozen(),
+ second.clone().into_frozen(),
+ third.clone().into_frozen(),
+ ])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ assert_eq!(journal.size_bytes(), (3 * PARTITION_WAL_BLOCK_SIZE) as u64);
+ assert_eq!(
+ journal.segment_checkpoint(),
+ Some(SegmentPosition::default())
+ );
+ journal.checkpoint(1).await.unwrap();
+ let checkpoint = SegmentPosition {
+ start_offset: 0,
+ length: BODY_BYTES as u64,
+ next_offset: 1,
+ };
+ assert_eq!(journal.segment_checkpoint(), Some(checkpoint));
+ journal.truncate_from(2).await.unwrap();
+ assert_eq!(
+ std::fs::metadata(partition.path().join("00000000000000000000.log"))
+ .unwrap()
+ .len(),
+ BODY_BYTES as u64
+ );
+ assert!(partition.path().join("00000000000000000002.log").exists());
+ let replacement = segment_prepare(2, first.header().checksum, 1, BODY_BYTES / 2);
+ journal
+ .append(replacement.clone().into_frozen())
+ .await
+ .unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.segment_checkpoint(), Some(checkpoint));
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), replacement.as_slice());
+ assert!(!partition.path().join("00000000000000000002.log").exists());
+ }
+
+ #[compio::test]
+ async fn owned_segments_recover_unpublished_bytes_and_keep_purged_prepare_bodies() {
+ const BODY_BYTES: usize = 8192;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (4 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ journal.append_buffered(second.into_frozen()).await.unwrap();
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(
+ std::fs::metadata(partition.path().join("00000000000000000000.log"))
+ .unwrap()
+ .len(),
+ BODY_BYTES as u64
+ );
+ journal.mark_purge(1, 1).await.unwrap();
+ let replacement = segment_prepare(2, first.header().checksum, 0, BODY_BYTES / 2);
+ journal
+ .append(replacement.clone().into_frozen())
+ .await
+ .unwrap();
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), replacement.as_slice());
+ assert_eq!(
+ std::fs::read(partition.path().join("00000000000000000000.log")).unwrap(),
+ replacement.as_slice()[size_of::<PrepareHeader>()..]
+ );
+ }
+
+ #[cfg(target_os = "linux")]
+ #[compio::test]
+ async fn given_preallocated_segments_when_recovering_and_replacing_should_keep_reservations() {
+ const SEGMENT_BYTES: u64 = 1024 * 1024;
+ const BODY_BYTES: usize = 4096;
+
+ for preallocate in [false, true] {
+ let partition = tempdir().unwrap();
+ let preallocation_supported = supports_preallocation(partition.path(), SEGMENT_BYTES);
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open_with_storage_and_capacity(
+ &directory,
+ 42,
+ 7,
+ DiskStorage,
+ PARTITION_WAL_BYTES_MAX,
+ preallocate,
+ )
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), SEGMENT_BYTES)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, usize::try_from(SEGMENT_BYTES).unwrap());
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ journal.append(second.clone().into_frozen()).await.unwrap();
+ journal.checkpoint(1).await.unwrap();
+ drop(journal);
+
+ let active = partition.path().join("00000000000000000001.log");
+ std::fs::OpenOptions::new()
+ .write(true)
+ .open(&active)
+ .unwrap()
+ .set_len(SEGMENT_BYTES + BODY_BYTES as u64)
+ .unwrap();
+ let mut journal = PartitionPrepareJournal::open_with_storage_and_capacity(
+ &directory,
+ 42,
+ 7,
+ DiskStorage,
+ PARTITION_WAL_BYTES_MAX,
+ preallocate,
+ )
+ .await
+ .unwrap();
+ let metadata = std::fs::metadata(&active).unwrap();
+ assert_eq!(metadata.len(), BODY_BYTES as u64);
+ if preallocation_supported {
+ assert_eq!(
+ metadata.blocks() * FILE_BLOCK_BYTES >= SEGMENT_BYTES,
+ preallocate,
+ "recovery must preserve the reservation after trimming unpublished bytes"
+ );
+ }
+ assert_eq!(
+ std::fs::read(&active).unwrap(),
+ second.as_slice()[size_of::<PrepareHeader>()..]
+ );
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), second.as_slice());
+
+ journal.truncate_from(2).await.unwrap();
+ let replacement = segment_prepare(2, first.header().checksum, 1, 2 * BODY_BYTES);
+ journal
+ .append(replacement.clone().into_frozen())
+ .await
+ .unwrap();
+ let metadata = std::fs::metadata(&active).unwrap();
+ assert_eq!(metadata.len(), (2 * BODY_BYTES) as u64);
+ if preallocation_supported {
+ assert_eq!(
+ metadata.blocks() * FILE_BLOCK_BYTES >= SEGMENT_BYTES,
+ preallocate,
+ "replacement segment must follow the preallocation policy"
+ );
+ }
+ assert_eq!(
+ std::fs::read(&active).unwrap(),
+ replacement.as_slice()[size_of::<PrepareHeader>()..]
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn owned_segments_install_checkpoint_bodies_with_and_without_public_data() {
+ const BODY_BYTES: usize = 8192;
+ for materialized in [true, false] {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open_with_storage_and_capacity(
+ &directory,
+ 42,
+ 7,
+ DiskStorage,
+ PARTITION_WAL_BYTES_MAX,
+ true,
+ )
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (4 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ let original = segment_prepare(1, 0, 0, BODY_BYTES);
+ journal.append(original.into_frozen()).await.unwrap();
+ std::fs::remove_file(partition.path().join("00000000000000000000.log")).unwrap();
+ let checkpoint = segment_prepare(7, 0, 5, BODY_BYTES);
+ let initial = if materialized {
+ write_segment(partition.path(), 0, 5, &checkpoint).await;
+ SegmentPosition {
+ start_offset: 5,
+ length: BODY_BYTES as u64,
+ next_offset: 6,
+ }
+ } else {
+ SegmentPosition {
+ start_offset: 6,
+ length: 0,
+ next_offset: 6,
+ }
+ };
+ journal
+ .reset_with_segment_checkpoint(
+ 7,
+ Some(checkpoint.header().checksum),
+ Some(checkpoint.clone().into_frozen()),
+ initial,
+ (4 * BODY_BYTES) as u64,
+ )
+ .await
+ .unwrap();
+ assert_eq!(journal.size_bytes(), PARTITION_WAL_BLOCK_SIZE as u64);
+ assert_eq!(journal.segment_checkpoint(), Some(initial));
+ #[cfg(target_os = "linux")]
+ {
+ let installed = std::fs::metadata(
+ partition
+ .path()
+ .join(format!("{:020}.log", initial.start_offset)),
+ )
+ .unwrap();
+ assert_eq!(installed.len(), initial.length);
+ if supports_preallocation(partition.path(), (4 * BODY_BYTES) as u64) {
+ assert!(installed.blocks() * FILE_BLOCK_BYTES >= (4 * BODY_BYTES) as u64);
+ }
+ }
+ let checkpoint_reference = journal.segment_reference(checkpoint.header()).unwrap();
+ if !materialized {
+ assert!(!partition.path().join("00000000000000000005.log").exists());
+ }
+ drop(journal);
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ checkpoint.as_slice()
+ );
+ assert_eq!(journal.segment_checkpoint(), Some(initial));
+ let next = segment_prepare(8, checkpoint.header().checksum, 6, BODY_BYTES);
+ journal.append(next.clone().into_frozen()).await.unwrap();
+ journal.checkpoint(8).await.unwrap();
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ next.as_slice()
+ );
+ assert_eq!(checkpoint_reference.path(&directory).exists(), materialized);
+ let expected = SegmentPosition {
+ length: initial.length + BODY_BYTES as u64,
+ next_offset: 7,
+ ..initial
+ };
+ assert_eq!(journal.segment_checkpoint(), Some(expected));
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.segment_checkpoint(), Some(expected));
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ next.as_slice()
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn owned_segments_preserve_overlapping_names_for_chain_validation() {
+ const BODY_BYTES: usize = 8192;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (4 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.append(second.clone().into_frozen()).await.unwrap();
+ journal.checkpoint(2).await.unwrap();
+ drop(journal);
+ let overlap = partition.path().join("00000000000000000001.log");
+ let unpublished = partition.path().join("00000000000000000002.log");
+ std::fs::write(&overlap, []).unwrap();
+ std::fs::write(&unpublished, b"unpublished suffix").unwrap();
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert!(
+ overlap.exists(),
+ "recovery must preserve the overlap for quarantine"
+ );
+ assert!(!unpublished.exists());
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ second.as_slice()
+ );
+ }
+
+ #[compio::test]
+ async fn owned_segments_migrate_only_unmaterialized_legacy_prepares() {
+ const EXISTING_GENERATION: u64 = 41;
+ const BODY_BYTES: usize = 8192;
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ let first = segment_prepare(1, 0, 0, BODY_BYTES);
+ let second = segment_prepare(2, first.header().checksum, 1, BODY_BYTES);
+ let existing_reference =
+ write_segment(partition.path(), EXISTING_GENERATION, 0, &first).await;
+ journal
+ .append_batch_referenced_buffered(
+ &[first.clone().into_frozen()],
+ &[Some(existing_reference)],
+ )
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ journal.append(second.clone().into_frozen()).await.unwrap();
+ let checkpoint = SegmentPosition {
+ start_offset: 0,
+ length: BODY_BYTES as u64,
+ next_offset: 1,
+ };
+ journal
+ .enable_segment_storage(checkpoint, (4 * BODY_BYTES) as u64)
+ .await
+ .unwrap();
+ assert_eq!(
+ journal.segment_reference(first.header()),
+ Some(existing_reference)
+ );
+ assert!(
+ journal
+ .segment_reference(second.header())
+ .unwrap()
+ .generation
+ > EXISTING_GENERATION
+ );
+ drop(journal);
+ let journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+ assert_eq!(journal.segment_checkpoint(), Some(checkpoint));
+ assert_eq!(
+ std::fs::metadata(partition.path().join("00000000000000000000.log"))
+ .unwrap()
+ .len(),
+ (2 * BODY_BYTES) as u64
+ );
+ let recovered = journal.prepares().await.unwrap();
+ assert_eq!(recovered[0].as_slice(), first.as_slice());
+ assert_eq!(recovered[1].as_slice(), second.as_slice());
+ }
+
+ fn segment_prepare(
+ op: u64,
+ parent: u128,
+ offset: u64,
+ body_length: usize,
+ ) -> Message<PrepareHeader> {
+ let template = sized_prepare(op, parent, size_of::<PrepareHeader>() + body_length);
+ let mut bytes = Owned::copy_from_slice(template.as_slice());
+ let body = &mut bytes.as_mut_slice()[size_of::<PrepareHeader>()..];
+ let mut batch = BatchHeader::new(42, 0, body_length as u64, 1);
+ batch.base_offset = offset;
+ batch.batch_checksum = batch.checksum_for_blob(&body[BATCH_HEADER_SIZE..]);
+ batch.encode_into(body);
+ let checksum = XxHash3_64::oneshot(body);
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+ &mut bytes.as_mut_slice()[..size_of::<PrepareHeader>()],
+ );
+ header.checksum_body = u128::from(checksum);
+ header.checksum = header.identity_checksum();
+ Message::try_from(bytes).unwrap()
+ }
+
+ fn prepare(op: u64, parent: u128) -> Message<PrepareHeader> {
+ sized_prepare(op, parent, size_of::<PrepareHeader>() + 16)
+ }
+
+ fn sized_prepare(op: u64, parent: u128, length: usize) -> Message<PrepareHeader> {
+ let mut buffer = Owned::<4096>::zeroed(length);
+ buffer.as_mut_slice()[size_of::<PrepareHeader>()..].fill(u8::try_from(op).unwrap());
+ let checksum_body = XxHash3_64::oneshot(&buffer.as_slice()[size_of::<PrepareHeader>()..]);
+ let length = buffer.as_slice().len();
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+ &mut buffer.as_mut_slice()[..size_of::<PrepareHeader>()],
+ );
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.group = 42;
+ header.op = op;
+ header.parent = parent;
+ header.size = u32::try_from(length).unwrap();
+ header.checksum_body = u128::from(checksum_body);
+ header.checksum = header.identity_checksum();
+ Message::try_from(buffer).unwrap()
+ }
+}
diff --git a/core/journal/src/partition_journal/segments.rs b/core/journal/src/partition_journal/segments.rs
new file mode 100644
index 0000000..6ff31cb
--- /dev/null
+++ b/core/journal/src/partition_journal/segments.rs
@@ -0,0 +1,848 @@
+// 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 std::collections::{BTreeMap, BTreeSet};
+use std::io;
+use std::path::{Path, PathBuf};
+
+use iggy_binary_protocol::batch::BatchHeader;
+use iggy_binary_protocol::{Operation, PrepareHeader};
+use iggy_common::MAX_TOPIC_SEGMENT_SIZE;
+use server_common::iobuf::{Frozen, IOV_MAX};
+
+use super::{
+ JournalState, PartitionPrepareJournal, SegmentReference, StoredPrepare, invalid, segment_path,
+};
+use crate::durable_storage::{DurableFile, DurableStorage, OpenMode};
+
+pub(super) const SEGMENT_STATE_FLAG: usize = 99;
+pub(super) const SEGMENT_STATE_OFFSET: usize = 128;
+pub(super) const SEGMENT_STATE_BYTES: usize = 10 * size_of::<u64>();
+
+const SEGMENT_RECOVERY_EXTENSION: &str = "log.tmp";
+
+/// A whole-batch boundary. `next_offset` is the first offset after this prefix.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct SegmentPosition {
+ pub start_offset: u64,
+ pub length: u64,
+ pub next_offset: u64,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) struct SegmentCursor {
+ pub generation: u64,
+ pub position: SegmentPosition,
+}
+
+/// Recovery materialization ends at `checkpoint`; live polls use the partition's
+/// committed segment size, which can advance beyond it within the durable tail.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) struct SegmentState {
+ pub max_size: u64,
+ pub next_generation: u64,
+ pub tail: SegmentCursor,
+ pub checkpoint: SegmentCursor,
+}
+
+impl<S: DurableStorage> PartitionPrepareJournal<S> {
+ /// Enable segment body ownership after recovering the legacy committed view.
+ /// The initial boundary must describe durable materialized messages only.
+ ///
+ /// # Errors
+ /// Returns an error for inconsistent boundaries, a `max_size` differing from an
+ /// already enabled layout, or a failed storage barrier.
+ pub async fn enable_segment_storage(
+ &mut self,
+ initial: SegmentPosition,
+ max_size: u64,
+ ) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if let Some(segments) = self.state.segment_storage {
+ if segments.max_size != max_size {
+ return Err(invalid("segment size differs from the durable WAL layout"));
+ }
+ return Ok(());
+ }
+ if !valid_segment_size(max_size) || !initial.valid() {
+ return Err(invalid("invalid initial segment boundary"));
+ }
+ let generation = self
+ .entries
+ .values()
+ .filter_map(|entry| entry.reference)
+ .map(|reference| reference.generation)
+ .max()
+ .map_or(Some(0), |generation| generation.checked_add(1))
+ .ok_or_else(|| invalid("segment generation exhausted"))?;
+ let cursor = SegmentCursor {
+ generation,
+ position: initial,
+ };
+ let segments = SegmentState {
+ max_size,
+ next_generation: generation
+ .checked_add(1)
+ .ok_or_else(|| invalid("segment generation exhausted"))?,
+ tail: cursor,
+ checkpoint: cursor,
+ };
+ let state = JournalState {
+ segment_references: true,
+ segment_storage: Some(segments),
+ ..self.state
+ };
+ let migrate = self.segment_migration_needed(segments).await?;
+ self.poisoned = true;
+ if initial.length > 0 {
+ self.open_segment_file(
+ generation,
+ initial.start_offset,
+ initial.length,
+ self.preallocate_segments.then_some(max_size),
+ )
+ .await?;
+ self.sync_segment_files().await?;
+ }
+ self.file.sync().await?;
+ // Upgrade before any uncommitted body reaches an offset-named file.
+ self.publish(state).await?;
+ self.state = state;
+ self.durable_head = state.head;
+ self.poisoned = false;
+ if migrate {
+ self.rewrite(
+ self.state.checkpoint,
+ self.state.checkpoint_checksum,
+ None,
+ None,
+ )
+ .await?;
+ }
+ Ok(())
+ }
+
+ pub const fn segment_checkpoint(&self) -> Option<SegmentPosition> {
+ match self.state.segment_storage {
+ Some(segments) => Some(segments.checkpoint.position),
+ None => None,
+ }
+ }
+
+ pub fn segment_reference(&self, header: &PrepareHeader) -> Option<SegmentReference> {
+ if !self.contains(header) {
+ return None;
+ }
+ self.entries
+ .get(&header.op)
+ .and_then(|entry| entry.reference)
+ }
+
+ /// Completed body writes at or above `from_op`, in operation order.
+ /// These references can precede durable publication.
+ pub fn written_segment_references(
+ &self,
+ from_op: u64,
+ ) -> impl Iterator<Item = (u64, SegmentReference)> + '_ {
+ self.entries
+ .range(from_op..)
+ .filter_map(|(&op, entry)| entry.reference.map(|reference| (op, reference)))
+ }
+
+ /// Install a transferred checkpoint after all replacement segment files are durable.
+ /// The replacement establishes its own segment size; it does not extend the old layout.
+ ///
+ /// # Errors
+ /// Returns an error if the checkpoint contradicts the installed bytes or a barrier fails.
+ #[allow(clippy::too_many_lines)]
+ pub async fn reset_with_segment_checkpoint(
+ &mut self,
+ op: u64,
+ checksum: Option<u128>,
+ prepare: Option<Frozen<4096>>,
+ initial: SegmentPosition,
+ max_size: u64,
+ ) -> io::Result<()> {
+ self.ensure_healthy()?;
+ if !initial.valid() || !valid_segment_size(max_size) {
+ return Err(invalid("invalid installed segment boundary"));
+ }
+ if let Some(prepare) = &prepare {
+ let header = self.validate_checkpoint_prepare(prepare)?;
+ if header.op != op || Some(header.checksum) != checksum {
+ return Err(invalid("installed checkpoint prepare identity mismatch"));
+ }
+ }
+ let generation = self
+ .state
+ .segment_storage
+ .map_or(0, |segments| segments.next_generation);
+ let cursor = SegmentCursor {
+ generation,
+ position: initial,
+ };
+ let mut segments = SegmentState {
+ max_size,
+ next_generation: generation
+ .checked_add(1)
+ .ok_or_else(|| invalid("segment generation exhausted"))?,
+ tail: cursor,
+ checkpoint: cursor,
+ };
+ let public = self
+ .segment_directory()?
+ .join(format!("{:020}.log", initial.start_offset));
+ match self.storage.open(&public, OpenMode::Read).await {
+ Ok(file) if file.length().await? != initial.length => {
+ return Err(invalid(
+ "installed segment size differs from its checkpoint",
+ ));
+ }
+ Ok(_) => {}
+ Err(error) if error.kind() == io::ErrorKind::NotFound && initial.length == 0 => {}
+ Err(error) => return Err(error),
+ }
+ self.poisoned = true;
+ self.open_segment_file(generation, initial.start_offset, initial.length, None)
+ .await?;
+ let installed_file = self
+ .segment_files
+ .get(&(generation, initial.start_offset))
+ .ok_or_else(|| invalid("installed segment handle is absent"))?;
+ if installed_file.length().await? != initial.length {
+ return Err(invalid(
+ "installed segment size differs from its checkpoint",
+ ));
+ }
+ if self.preallocate_segments {
+ installed_file.preallocate(&cursor.path(&self.directory), max_size);
+ }
+ let checkpoint_prepare = if let Some(prepare) = prepare {
+ let header = self.validate_checkpoint_prepare(&prepare)?;
+ let reference = if header.operation == Operation::SendMessages {
+ let batch = decode_batch(prepare.as_slice())?;
+ if initial.length >= batch.batch_length
+ && batch_next_offset(prepare.as_slice())? == initial.next_offset
+ {
+ let reference = SegmentReference {
+ generation,
+ start_offset: initial.start_offset,
+ position: initial.length - batch.batch_length,
+ length: batch.batch_length,
+ };
+ let file = self
+ .segment_files
+ .get(&(generation, initial.start_offset))
+ .ok_or_else(|| invalid("installed segment handle is absent"))?;
+ if file
+ .read(
+ reference.position,
+ prepare.len() - size_of::<PrepareHeader>(),
+ )
+ .await?
+ != prepare.as_slice()[size_of::<PrepareHeader>()..]
+ {
+ return Err(invalid(
+ "checkpoint prepare differs from installed segment bytes",
+ ));
+ }
+ Some(reference)
+ } else if let Some(reference) = self
+ .entries
+ .get(&op)
+ .filter(|entry| entry.checksum == header.checksum)
+ .and_then(|entry| entry.reference)
+ {
+ Some(reference)
+ } else {
+ let retained = segments.allocate(SegmentPosition {
+ start_offset: batch.base_offset,
+ length: batch.batch_length,
+ next_offset: batch_next_offset(prepare.as_slice())?,
+ })?;
+ let reference = SegmentReference {
+ generation: retained.generation,
+ start_offset: batch.base_offset,
+ position: 0,
+ length: batch.batch_length,
+ };
+ let mut file = self
+ .storage
+ .open(&reference.path(&self.directory), OpenMode::Create)
+ .await?;
+ file.write_frozen(0, prepare.slice(size_of::<PrepareHeader>()..))
+ .await?;
+ file.sync().await?;
+ Some(reference)
+ }
+ } else {
+ None
+ };
+ Some((prepare, reference))
+ } else {
+ None
+ };
+ self.sync_segment_files().await?;
+ self.storage.sync_directory(&self.directory).await?;
+ self.state.segment_references = true;
+ self.state.segment_storage = Some(segments);
+ self.state.checkpoint = op;
+ self.state.anchor_known = checksum.is_some();
+ self.state.certified_log_view = None;
+ self.rewrite(op, checksum.unwrap_or(0), Some(0), checkpoint_prepare)
+ .await
+ }
+
+ pub(super) async fn migrate_segment_prepares(&mut self) -> io::Result<()> {
+ if let Some(segments) = self.state.segment_storage
+ && (self
+ .entries
+ .range(..=self.state.purge_floor)
+ .any(|(_, entry)| entry.reference.is_some())
+ || self.segment_migration_needed(segments).await?)
+ {
+ self.rewrite(
+ self.state.checkpoint,
+ self.state.checkpoint_checksum,
+ None,
+ None,
+ )
+ .await?;
+ }
+ Ok(())
+ }
+
+ async fn segment_migration_needed(&self, mut segments: SegmentState) -> io::Result<bool> {
+ let mut convertible = false;
+ for (_, entry) in self
+ .entries
+ .range(self.state.purge_floor.saturating_add(1)..)
+ {
+ if entry.reference.is_some() {
+ continue;
+ }
+ let (header, _, prepare, _) = self.read_record(entry.position).await?;
+ if header.operation == Operation::SendMessages
+ && decode_batch(prepare.as_slice())?.base_offset
+ >= segments.tail.position.next_offset
+ {
+ // Validate the complete migration before rewrite can modify files.
+ segments.reserve(&header, prepare.as_slice())?;
+ convertible = true;
+ }
+ }
+ Ok(convertible)
+ }
+
+ pub(super) async fn write_segment_bodies(
+ &mut self,
+ prepares: &[Frozen<4096>],
+ records: &[(u64, StoredPrepare, usize)],
+ ) -> io::Result<()> {
+ if self.state.segment_storage.is_none() {
+ return Ok(());
+ }
+ let mut bodies = records
+ .iter()
+ .filter_map(|(_, record, index)| record.reference.map(|reference| (reference, *index)))
+ .peekable();
+ if bodies.peek().is_none() {
+ return Ok(());
+ }
+ while let Some((first, index)) = bodies.next() {
+ let mut buffers = Vec::with_capacity(records.len().min(IOV_MAX));
+ buffers.push(prepares[index].slice(size_of::<PrepareHeader>()..));
+ let mut end = first.position + first.length;
+ while buffers.len() < IOV_MAX {
+ let Some(&(reference, index)) = bodies.peek() else {
+ break;
+ };
+ if reference.generation != first.generation
+ || reference.start_offset != first.start_offset
+ || reference.position != end
+ {
+ break;
+ }
+ end += reference.length;
+ buffers.push(prepares[index].slice(size_of::<PrepareHeader>()..));
+ bodies.next();
+ }
+ self.segment_writing_file(first)
+ .await?
+ .write_frozen_vectored(first.position, buffers)
+ .await?;
+ }
+ Ok(())
+ }
+
+ pub(super) async fn write_segment_body(
+ &mut self,
+ reference: SegmentReference,
+ prepare: &Frozen<4096>,
+ ) -> io::Result<()> {
+ self.segment_writing_file(reference)
+ .await?
+ .write_frozen(
+ reference.position,
+ prepare.slice(size_of::<PrepareHeader>()..),
+ )
+ .await
+ }
+
+ async fn segment_writing_file(
+ &mut self,
+ reference: SegmentReference,
+ ) -> io::Result<&mut S::File> {
+ let preallocate_size = self
+ .state
+ .segment_storage
+ .filter(|_| self.preallocate_segments)
+ .map(|segments| segments.max_size);
+ self.open_segment_file(
+ reference.generation,
+ reference.start_offset,
+ reference.position,
+ preallocate_size,
+ )
+ .await?;
+ self.segment_files_dirty = true;
+ self.segment_files
+ .get_mut(&(reference.generation, reference.start_offset))
+ .ok_or_else(|| invalid("segment writing handle is absent"))
+ }
+
+ pub(super) async fn sync_segment_files(&mut self) -> io::Result<()> {
+ if self.segment_files_dirty {
+ for file in self.segment_files.values() {
+ // Keep the writing handle: reopening after an errseq writeback error
+ // could turn a failed body barrier into a successful acknowledgment.
+ file.sync().await?;
+ }
+ self.segment_files_dirty = false;
+ }
+ if self.segment_links_dirty {
+ self.storage.sync_directory(&self.directory).await?;
+ self.storage
+ .sync_directory(self.segment_directory()?)
+ .await?;
+ self.segment_links_dirty = false;
+ }
+ Ok(())
+ }
+
+ pub(super) fn retain_active_segment_file(&mut self) {
+ // Buffered rotations must retain their original writers until publication.
+ if let Some(segments) = self.state.segment_storage {
+ let active = (
+ segments.tail.generation,
+ segments.tail.position.start_offset,
+ );
+ self.segment_files.retain(|key, _| *key == active);
+ }
+ }
+
+ pub(super) fn retained_segment_paths(
+ &self,
+ entries: &BTreeMap<u64, StoredPrepare>,
+ segments: Option<SegmentState>,
+ ) -> BTreeSet<PathBuf> {
+ let mut paths = super::referenced_segments(entries, &self.directory);
+ if let Some(segments) = segments {
+ paths.insert(segments.tail.path(&self.directory));
+ paths.insert(segments.checkpoint.path(&self.directory));
+ }
+ paths
+ }
+
+ pub(super) fn segment_boundary(&self, through_op: u64) -> Option<SegmentCursor> {
+ let segments = self.state.segment_storage?;
+ self.entries
+ .range(..=through_op)
+ .rev()
+ .find_map(|(&op, entry)| {
+ let reference = entry.reference?;
+ let next_offset = entry.next_offset?;
+ (op > self.state.purge_floor
+ && op > self.state.checkpoint
+ && next_offset >= segments.checkpoint.position.next_offset)
+ .then_some(SegmentCursor {
+ generation: reference.generation,
+ position: SegmentPosition {
+ start_offset: reference.start_offset,
+ length: reference.position + reference.length,
+ next_offset,
+ },
+ })
+ })
+ .or(Some(segments.checkpoint))
+ }
+
+ pub(super) async fn recover_segment_files(&mut self) -> io::Result<()> {
+ let Some(segments) = self.state.segment_storage else {
+ return Ok(());
+ };
+ self.poisoned = true;
+ let parent = self.segment_directory()?.to_path_buf();
+ let cursor = segments.tail;
+ let public = parent.join(format!("{:020}.log", cursor.position.start_offset));
+ // Retention may remove a fully checkpointed sealed segment. Its private
+ // checkpoint prepare remains available without restoring polled data.
+ let restore = cursor.position.length < segments.max_size
+ || cursor.position.next_offset > segments.checkpoint.position.next_offset
+ || self.storage.exists(&public).await?;
+ if restore {
+ self.open_segment_file(
+ cursor.generation,
+ cursor.position.start_offset,
+ cursor.position.length,
+ self.preallocate_segments.then_some(segments.max_size),
+ )
+ .await?;
+ let file = self
+ .segment_files
+ .get(&(cursor.generation, cursor.position.start_offset))
+ .ok_or_else(|| invalid("segment rollback handle is absent"))?;
+ let length = file.length().await?;
+ if length < cursor.position.length {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "segment {} (generation {}, start offset {}) lost durably published bytes: expected at least {}, found {}",
+ cursor.path(&self.directory).display(),
+ cursor.generation,
+ cursor.position.start_offset,
+ cursor.position.length,
+ length,
+ ),
+ ));
+ }
+ if length > cursor.position.length {
+ file.truncate(cursor.position.length).await?;
+ if self.preallocate_segments {
+ file.preallocate(&cursor.path(&self.directory), segments.max_size);
+ }
+ }
+ self.sync_segment_files().await?;
+ // Keep the public name present across recovery crashes. Its absence
+ // on a fully checkpointed sealed tail means retention removed it.
+ let temporary = public.with_extension(SEGMENT_RECOVERY_EXTENSION);
+ self.remove_segment_name(&temporary).await?;
+ self.storage
+ .hard_link(&cursor.path(&self.directory), &temporary)
+ .await?;
+ self.storage.rename(&temporary, &public).await?;
+ // Renaming two links to the same inode leaves both names intact.
+ self.remove_segment_name(&temporary).await?;
+ }
+ for entry in self.storage.entries(&parent).await? {
+ let Some(name) = entry.name.to_str() else {
+ continue;
+ };
+ let offset = name
+ .strip_suffix(".log")
+ .or_else(|| name.strip_suffix(".index"))
+ .and_then(|offset| offset.parse::<u64>().ok());
+ if !entry.directory
+ && offset.is_some_and(|offset| {
+ offset > cursor.position.start_offset && offset >= cursor.position.next_offset
+ })
+ {
+ self.storage.remove_file(&parent.join(entry.name)).await?;
+ }
+ }
+ self.storage.sync_directory(&parent).await?;
+ self.retain_active_segment_file();
+ self.poisoned = false;
+ Ok(())
+ }
+
+ pub(super) async fn truncate_segment_tail(&mut self) -> io::Result<()> {
+ let Some(segments) = self.state.segment_storage else {
+ return Ok(());
+ };
+ self.poisoned = true;
+ let cursor = segments.tail;
+ let key = (cursor.generation, cursor.position.start_offset);
+ if !self.segment_files.contains_key(&key) {
+ let path = cursor.path(&self.directory);
+ match self.storage.open(&path, OpenMode::ReadWrite).await {
+ Ok(file) => {
+ self.segment_files.insert(key, file);
+ }
+ Err(error)
+ if error.kind() == io::ErrorKind::NotFound && cursor.position.length == 0 =>
+ {
+ self.poisoned = false;
+ return Ok(());
+ }
+ Err(error) => return Err(error),
+ }
+ }
+ // Live partitions own the public log/index names and may still hold their
+ // descriptors. Namespace reconciliation belongs to startup recovery.
+ let file = self
+ .segment_files
+ .get(&key)
+ .ok_or_else(|| invalid("segment rollback handle is absent"))?;
+ if file.length().await? < cursor.position.length {
+ return Err(invalid("segment lost durably published bytes"));
+ }
+ file.truncate(cursor.position.length).await?;
+ file.sync().await?;
+ self.poisoned = false;
+ Ok(())
+ }
+
+ async fn open_segment_file(
+ &mut self,
+ generation: u64,
+ start_offset: u64,
+ length: u64,
+ preallocate_size: Option<u64>,
+ ) -> io::Result<()> {
+ let key = (generation, start_offset);
+ if self.segment_files.contains_key(&key) {
+ return Ok(());
+ }
+ let parent = self.segment_directory()?.to_path_buf();
+ let retained = segment_path(&self.directory, generation, start_offset);
+ let public = parent.join(format!("{start_offset:020}.log"));
+ let file = if self.storage.exists(&retained).await? {
+ self.storage.open(&retained, OpenMode::ReadWrite).await?
+ } else {
+ if length == 0
+ && self.storage.exists(&public).await?
+ && self
+ .storage
+ .open(&public, OpenMode::Read)
+ .await?
+ .length()
+ .await?
+ > 0
+ {
+ // A purged inode can still be retained by older prepares.
+ self.remove_segment_name(&public).await?;
+ }
+ // Segment roll can create the public name during any await. Both
+ // creators must open that inode without truncation, then retain it.
+ let mode = if length == 0 {
+ OpenMode::CreateOrOpen
+ } else {
+ OpenMode::ReadWrite
+ };
+ let file = self.storage.open(&public, mode).await?;
+ self.storage.hard_link(&public, &retained).await?;
+ if length == 0
+ && let Some(size) = preallocate_size
+ {
+ file.preallocate(&retained, size);
+ }
+ file
+ };
+ self.segment_files_dirty = true;
+ self.segment_links_dirty = true;
+ self.segment_files.insert(key, file);
+ Ok(())
+ }
+
+ fn segment_directory(&self) -> io::Result<&Path> {
+ self.directory
+ .parent()
+ .ok_or_else(|| invalid("WAL has no segment directory"))
+ }
+
+ async fn remove_segment_name(&self, path: &Path) -> io::Result<()> {
+ match self.storage.remove_file(path).await {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ }
+ }
+}
+
+impl SegmentPosition {
+ const fn valid(self) -> bool {
+ self.next_offset >= self.start_offset
+ && ((self.length == 0) == (self.next_offset == self.start_offset))
+ }
+}
+
+impl SegmentCursor {
+ fn path(self, directory: &Path) -> PathBuf {
+ segment_path(directory, self.generation, self.position.start_offset)
+ }
+}
+
+impl SegmentState {
+ pub(super) fn valid(self) -> bool {
+ valid_segment_size(self.max_size)
+ && self.tail.generation < self.next_generation
+ && self.checkpoint.generation < self.next_generation
+ && self.tail.position.valid()
+ && self.checkpoint.position.valid()
+ && self.tail.position.next_offset >= self.checkpoint.position.next_offset
+ }
+
+ pub(super) fn reserve(
+ &mut self,
+ header: &PrepareHeader,
+ prepare: &[u8],
+ ) -> io::Result<(Option<SegmentReference>, Option<u64>)> {
+ if header.operation != Operation::SendMessages {
+ return Ok((None, None));
+ }
+ let batch = decode_batch(prepare)?;
+ if batch.base_offset != self.tail.position.next_offset {
+ return Err(invalid(
+ "message offsets do not extend the physical segment tail",
+ ));
+ }
+ if self.tail.position.length >= self.max_size {
+ self.tail = self.allocate(SegmentPosition {
+ start_offset: batch.base_offset,
+ length: 0,
+ next_offset: batch.base_offset,
+ })?;
+ }
+ let reference = SegmentReference {
+ generation: self.tail.generation,
+ start_offset: self.tail.position.start_offset,
+ position: self.tail.position.length,
+ length: batch.batch_length,
+ };
+ self.tail.position.length = reference
+ .position
+ .checked_add(reference.length)
+ .ok_or_else(|| invalid("segment position exhausted"))?;
+ self.tail.position.next_offset = batch_next_offset(prepare)?;
+ Ok((Some(reference), Some(self.tail.position.next_offset)))
+ }
+
+ pub(super) fn reset_position(&mut self, position: SegmentPosition) -> io::Result<()> {
+ if !position.valid() {
+ return Err(invalid("invalid replacement segment boundary"));
+ }
+ self.tail = self.allocate(position)?;
+ self.checkpoint = self.tail;
+ Ok(())
+ }
+
+ fn allocate(&mut self, position: SegmentPosition) -> io::Result<SegmentCursor> {
+ let generation = self.next_generation;
+ self.next_generation = generation
+ .checked_add(1)
+ .ok_or_else(|| invalid("segment generation exhausted"))?;
+ Ok(SegmentCursor {
+ generation,
+ position,
+ })
+ }
+
+ pub(super) fn encode(self, bytes: &mut [u8]) {
+ let values = [
+ self.max_size,
+ self.next_generation,
+ self.tail.generation,
+ self.tail.position.start_offset,
+ self.tail.position.length,
+ self.tail.position.next_offset,
+ self.checkpoint.generation,
+ self.checkpoint.position.start_offset,
+ self.checkpoint.position.length,
+ self.checkpoint.position.next_offset,
+ ];
+ for (field, value) in bytes
+ .as_chunks_mut::<{ size_of::<u64>() }>()
+ .0
+ .iter_mut()
+ .zip(values)
+ {
+ *field = value.to_le_bytes();
+ }
+ }
+
+ pub(super) fn decode(bytes: &[u8]) -> io::Result<Self> {
+ let mut values = [0; SEGMENT_STATE_BYTES / size_of::<u64>()];
+ for (value, field) in values
+ .iter_mut()
+ .zip(bytes.as_chunks::<{ size_of::<u64>() }>().0)
+ {
+ *value = u64::from_le_bytes(*field);
+ }
+ let [
+ max_size,
+ next_generation,
+ tail_generation,
+ tail_start,
+ tail_length,
+ tail_next,
+ checkpoint_generation,
+ checkpoint_start,
+ checkpoint_length,
+ checkpoint_next,
+ ] = values;
+ let state = Self {
+ max_size,
+ next_generation,
+ tail: SegmentCursor {
+ generation: tail_generation,
+ position: SegmentPosition {
+ start_offset: tail_start,
+ length: tail_length,
+ next_offset: tail_next,
+ },
+ },
+ checkpoint: SegmentCursor {
+ generation: checkpoint_generation,
+ position: SegmentPosition {
+ start_offset: checkpoint_start,
+ length: checkpoint_length,
+ next_offset: checkpoint_next,
+ },
+ },
+ };
+ if !state.valid() {
+ return Err(invalid("invalid durable segment boundaries"));
+ }
+ Ok(state)
+ }
+}
+
+fn valid_segment_size(max_size: u64) -> bool {
+ // The journal supports small test layouts; real topics validate their lower
+ // bound and alignment at admission. Recovery must still cap allocation.
+ (1..=MAX_TOPIC_SEGMENT_SIZE).contains(&max_size)
+}
+
+pub(super) fn batch_next_offset(prepare: &[u8]) -> io::Result<u64> {
+ let batch = decode_batch(prepare)?;
+ batch
+ .base_offset
+ .checked_add(u64::from(batch.message_count))
+ .ok_or_else(|| invalid("message offset exhausted"))
+}
+
+pub(super) fn decode_batch(prepare: &[u8]) -> io::Result<BatchHeader> {
+ let body = prepare
+ .get(size_of::<PrepareHeader>()..)
+ .ok_or_else(|| invalid("short message prepare"))?;
+ let batch = BatchHeader::decode(body).map_err(|_| invalid("invalid segment batch header"))?;
+ if batch.batch_length != body.len() as u64 || batch.message_count == 0 {
+ return Err(invalid("invalid segment batch bounds"));
+ }
+ Ok(batch)
+}
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index 1522cfb..3472094 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -3465,7 +3465,12 @@
// node default is by then, not the value resolved at creation.
// Re-encoding also canonicalizes kinds (a `"128MiB"` string
// becomes `Uint64`), so the stored map reads back uniformly.
- request.options = explicit.to_wire()?;
+ let supplied_options = request.options.clone();
+ request.options = explicit.to_explicit_wire(|key| {
+ supplied_options
+ .into_iter()
+ .any(|entry| entry.key == key.as_bytes())
+ })?;
let resolved_segment_size = explicit
.segment_size
.unwrap_or_else(|| IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE));
@@ -3494,9 +3499,8 @@
resolved_max_topic_size,
TopicRuntimeDefaults {
segment_size: resolved_segment_size,
- enforce_fsync: explicit
- .enforce_fsync
- .unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC),
+ durability: iggy_common::Durability::default(),
+ consumer_offset_durability: iggy_common::Durability::default(),
messages_required_to_save: explicit
.messages_required_to_save
.unwrap_or(iggy_common::DEFAULT_MESSAGES_REQUIRED_TO_SAVE),
@@ -3511,6 +3515,7 @@
.preallocate_segments
.unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS),
},
+ &supplied_options,
)?;
let partitions = self
.allocator
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index f2707ea..9844d55 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -2751,6 +2751,8 @@
CreateTopicRequest as WireCreateTopicRequest, CreateTopicWithAssignmentsRequest,
};
use iggy_binary_protocol::responses::topics::get_topic::GetTopicResponse;
+ use iggy_common::{HeaderKey, HeaderKind, topic_option_keys};
+ use std::str::FromStr;
#[test]
fn truncate_partition_request_round_trips() {
@@ -2779,9 +2781,6 @@
#[test]
fn create_topic_stores_merged_options_and_typed_fields() {
- use iggy_common::{HeaderKey, HeaderKind, TopicCreateOptions, topic_option_keys};
- use std::str::FromStr;
-
let mut inner = StreamsInner::new();
create_stream(&mut inner, "s");
@@ -2802,7 +2801,9 @@
stream_id: WireIdentifier::numeric(0),
partitions_count: 1,
name: WireName::new("t").unwrap(),
- options: explicit.to_wire().unwrap(),
+ options: explicit
+ .to_explicit_wire(|key| key == topic_option_keys::MESSAGE_EXPIRY)
+ .unwrap(),
},
derived_options: derived.to_wire().unwrap(),
partitions: vec![CreatedPartitionAssignment {
@@ -2820,11 +2821,24 @@
assert_eq!(topic.compression_algorithm, CompressionAlgorithm::None);
// partitions_count is create-consumed, never persisted.
- assert_eq!(topic.options.len(), 3);
+ assert_eq!(topic.options.len(), 5);
let expiry_key = HeaderKey::from_str(topic_option_keys::MESSAGE_EXPIRY).unwrap();
let expiry = topic.options.get(&expiry_key).unwrap();
assert!(expiry.explicit, "client-sent key keeps its provenance");
assert_eq!(expiry.value.kind(), HeaderKind::Uint64);
+ for policy in [
+ topic_option_keys::DURABILITY,
+ topic_option_keys::CONSUMER_OFFSET_DURABILITY,
+ ] {
+ let key = HeaderKey::from_str(policy).unwrap();
+ let option = topic.options.get(&key).unwrap();
+ assert!(
+ !option.explicit,
+ "unsupplied durability comes from admission defaults"
+ );
+ assert_eq!(option.value.kind(), HeaderKind::String);
+ assert_eq!(option.value.as_str().unwrap(), "replicated");
+ }
let size_key = HeaderKey::from_str(topic_option_keys::MAX_TOPIC_SIZE).unwrap();
assert!(
!topic.options.get(&size_key).unwrap().explicit,
diff --git a/core/partitions/Cargo.toml b/core/partitions/Cargo.toml
index 3a1b48c..41524d6 100644
--- a/core/partitions/Cargo.toml
+++ b/core/partitions/Cargo.toml
@@ -56,7 +56,6 @@
iggy_common = { workspace = true }
journal = { workspace = true }
message_bus = { workspace = true }
-nix = { workspace = true }
papaya = { workspace = true }
ringbuffer = { workspace = true }
server_common = { workspace = true }
@@ -64,6 +63,9 @@
tokio = { workspace = true }
tracing = { workspace = true }
+[target.'cfg(unix)'.dependencies]
+nix = { workspace = true }
+
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index e6a2219..52da8e5 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -27,6 +27,7 @@
PURGE_GENERATION_FILE, delete_persisted_offset, persist_offset, persist_offset_max,
persist_purge_generation, read_purge_generation,
};
+use crate::persistence::{PartitionPersistence, PersistenceCompletion, PersistenceNotifier};
use crate::poll_plan::{
AutoCommitCtx, AutoCommitTarget, DiskReadPlan, DiskSegment, LastPolledCtx,
PartitionDirResolution, PollPlan, PollTier, ResidentTailSnapshot,
@@ -38,6 +39,7 @@
AppendResult, Partition, PartitionOffsets, PartitionsConfig, PollQueryResult, PollingArgs,
PollingConsumer,
};
+use consensus::Pipeline;
use consensus::{
ClientTable, ClientTableMode, CommitLogEvent, Consensus, PartitionDiagEvent, PipelineEntry,
PlaneKind, Project, ReplicaLogContext, RequestLogEvent, Sequencer, SimEventKind, VsrConsensus,
@@ -45,7 +47,8 @@
build_reply_message, drain_committable_prefix, emit_namespace_progress_event,
emit_partition_diag, emit_sim_event, fence_old_prepare_by_commit, repair_session_live,
repaired_frontier_update, replicate_frozen_to_next_in_chain, replicate_preflight,
- restamp_prepare_view, send_prepare_ok as send_prepare_ok_common, verify_prepare_integrity,
+ report_uncommittable_head, restamp_prepare_view, send_prepare_ok as send_prepare_ok_common,
+ verify_prepare_integrity,
};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
@@ -79,7 +82,7 @@
sharding::IggyNamespace,
};
use std::cell::{Cell, RefCell};
-use std::collections::{HashMap, HashSet};
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::hash::Hash;
use std::num::NonZeroU32;
@@ -147,12 +150,16 @@
/// path transiently disappears and silently hides the disk tier.
/// `None` only for in-memory (simulated) partitions.
pub(crate) partition_dir: Option<String>,
- pub(crate) consumer_offset_enforce_fsync: bool,
+ segment_names_dirty: Cell<bool>,
/// This topic's runtime knobs, resolved at topic admission and carried
/// here by the builder. Every `None` field falls back to the shard-wide
/// `PartitionsConfig` value (simulator and tests build partitions with
/// no resolved options at all).
pub(crate) runtime_options: TopicRuntimeOptions,
+ pub(crate) persistence: Option<Rc<PartitionPersistence>>,
+ pub(crate) materialization_missing: bool,
+ recovered_log_view: Option<u32>,
+ pending_persisted_acks: RefCell<BTreeMap<u64, PrepareHeader>>,
/// In-flight journal repair:
/// set when the recovery handshake finds this replica behind the group's
/// commit frontier, cleared when `RepairDone` completes the walk.
@@ -527,6 +534,14 @@
}
}
+impl<B: MessageBus, SB> Drop for IggyPartition<B, SB> {
+ fn drop(&mut self) {
+ if let Some(persistence) = &self.persistence {
+ persistence.retire();
+ }
+ }
+}
+
impl<B, SB> IggyPartition<B, SB>
where
B: MessageBus,
@@ -555,8 +570,12 @@
consumer_offsets_path: None,
consumer_group_offsets_path: None,
partition_dir: None,
- consumer_offset_enforce_fsync: false,
+ segment_names_dirty: Cell::new(true),
runtime_options: TopicRuntimeOptions::default(),
+ persistence: None,
+ materialization_missing: false,
+ recovered_log_view: None,
+ pending_persisted_acks: RefCell::new(BTreeMap::new()),
repair: None,
gap_ticks: Cell::new(0),
prepare_gap_drops: Cell::new(0),
@@ -692,10 +711,8 @@
stats: Arc<PartitionStats>,
consensus: VsrConsensus<B>,
segment_size: IggyByteSize,
- consumer_offset_enforce_fsync: bool,
) -> Self {
let mut partition = Self::new(stats, consensus);
- partition.consumer_offset_enforce_fsync = consumer_offset_enforce_fsync;
let start_offset = 0;
let segment = Segment::new(start_offset, segment_size);
let storage = SegmentStorage::default();
@@ -711,8 +728,523 @@
partition
}
+ pub fn set_persistence_notifier(&self, notifier: PersistenceNotifier) {
+ if let Some(persistence) = &self.persistence {
+ persistence.set_notifier(notifier);
+ }
+ }
+
+ /// # Errors
+ /// Returns an error if durable prepare history cannot be opened or replayed.
+ pub async fn open_persistence(&mut self) -> Result<(), IggyError> {
+ self.open_persistence_with_capacity(journal::partition_journal::PARTITION_WAL_BYTES_MAX)
+ .await
+ }
+
+ /// # Errors
+ /// Returns an error if durable prepare history cannot be opened or replayed.
+ pub async fn open_persistence_with_capacity(&mut self, capacity: u64) -> Result<(), IggyError> {
+ self.open_persistence_with_recovered(capacity, None).await
+ }
+
+ /// # Errors
+ /// Returns an error if durable history cannot be opened, migrated, or replayed.
+ #[allow(clippy::too_many_lines)]
+ pub async fn open_persistence_with_recovered(
+ &mut self,
+ capacity: u64,
+ recovered: Option<(Rc<PartitionPersistence>, Vec<Message<PrepareHeader>>)>,
+ ) -> Result<(), IggyError> {
+ if self.consensus.replica_count() > 1
+ && let Some(directory) = &self.partition_dir
+ {
+ self.materialization_missing =
+ crate::state_transfer::materialization_is_missing(directory, self.created_revision)
+ .await
+ .map_err(|_| IggyError::CannotReadFile)?;
+ self.ensure_materialization_recovery();
+ }
+ if self.consensus.replica_count() == 1
+ || !(self.durability().is_persisted()
+ || self.consumer_offset_durability().is_persisted())
+ {
+ return Ok(());
+ }
+ let directory = self
+ .partition_dir
+ .as_ref()
+ .ok_or(IggyError::CannotReadFile)?;
+ let directory =
+ std::path::Path::new(directory).join(format!("prepares-{}", self.created_revision));
+ let (persistence, prepares) = if let Some(recovered) = recovered {
+ recovered
+ } else {
+ PartitionPersistence::open_with_capacity(
+ &directory,
+ self.namespace().inner(),
+ self.created_revision,
+ journal::durable_storage::DiskStorage,
+ capacity,
+ self.runtime_options
+ .preallocate_segments
+ .unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS),
+ )
+ .await
+ .map_err(|error| {
+ warn!(%error, "cannot open partition prepare WAL");
+ IggyError::CannotReadFile
+ })?
+ };
+ if !self.materialization_missing {
+ let segment = self.log.active_segment();
+ let length = segment.size.as_bytes_u64();
+ let initial = journal::partition_journal::SegmentPosition {
+ start_offset: segment.start_offset,
+ length,
+ next_offset: if length == 0 {
+ segment.start_offset
+ } else {
+ segment
+ .end_offset
+ .checked_add(1)
+ .ok_or(IggyError::CannotReadFile)?
+ },
+ };
+ persistence.enable_segment_storage(initial, segment.max_size.as_bytes_u64());
+ if persistence.start() {
+ self.consensus
+ .message_bus()
+ .spawn(Rc::clone(&persistence).run());
+ }
+ persistence.drain_with_timeout().await.map_err(|error| {
+ warn!(%error, "cannot enable partition segment persistence");
+ IggyError::CannotSyncFile
+ })?;
+ }
+ self.restore_certified_log_view(&persistence).await?;
+ if self.materialization_missing {
+ self.persistence = Some(persistence);
+ return Ok(());
+ }
+ let (purge_generation, purge_floor) = persistence.purge_marker();
+ if purge_generation <= self.applied_purge_generation {
+ self.purge_floor_op = self.purge_floor_op.max(purge_floor);
+ }
+ let checkpoint = persistence.checkpoint_op();
+ let head = persistence.head();
+ let mut commit = checkpoint;
+ for message in prepares {
+ let header = *message.header();
+ if header.op == checkpoint {
+ self.log
+ .journal()
+ .inner
+ .restore_checkpoint_prepare(checkpoint, message.into_frozen());
+ continue;
+ }
+ commit = commit.max(header.commit.min(head));
+ if header.operation == Operation::SendMessages {
+ self.append_repaired_send_messages(message).await?;
+ } else {
+ self.apply_replicated_operation(message).await?;
+ }
+ }
+ if head > 0 {
+ self.consensus.sequencer().set_sequence(head);
+ if let Some(checksum) = persistence.checksum(head) {
+ self.consensus.set_last_prepare_checksum(checksum);
+ }
+ self.consensus.restore_commit_state(checkpoint, commit);
+ }
+ // Recovery can reuse completed writes whose last barrier was interrupted.
+ // Include them once before reclaiming any recovered WAL history.
+ for segment in self.log.segments() {
+ persistence.mark_segment_dirty(segment.start_offset);
+ }
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ self.durable_consumer_offsets.with_entries(kind, |entries| {
+ for consumer_id in entries.keys() {
+ persistence.mark_offset_dirty(
+ crate::state_transfer::consumer_kind_index(kind),
+ *consumer_id,
+ true,
+ );
+ }
+ });
+ }
+ self.persistence = Some(persistence);
+ Ok(())
+ }
+
+ async fn restore_certified_log_view(
+ &mut self,
+ persistence: &Rc<PartitionPersistence>,
+ ) -> Result<(), IggyError> {
+ if !self.materialization_missing
+ && self.recovered_log_view.is_none()
+ && persistence.head() == 0
+ {
+ persistence.certify_log_view(self.consensus.log_view(), 0, 0);
+ if persistence.start() {
+ self.consensus
+ .message_bus()
+ .spawn(Rc::clone(persistence).run());
+ }
+ persistence
+ .drain_with_timeout()
+ .await
+ .map_err(|_| IggyError::CannotSyncFile)?;
+ }
+ if !self.materialization_missing {
+ match persistence.certified_log_view() {
+ Some(view) if view >= self.consensus.log_view() => {
+ if view > self.consensus.view() {
+ self.consensus.set_view(view);
+ }
+ self.consensus.set_log_view(view);
+ }
+ _ => {
+ self.materialization_missing = true;
+ self.ensure_materialization_recovery();
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub const fn requires_state_transfer(&self) -> bool {
+ self.materialization_missing
+ }
+
+ pub fn ensure_materialization_recovery(&self) {
+ if self.materialization_missing
+ && (self.consensus.state_transfer_stage() == consensus::StateTransferStage::Idle
+ || self.consensus.status() == consensus::Status::ViewChange)
+ {
+ self.consensus.begin_view_probe();
+ if self.consensus.state_transfer_stage() == consensus::StateTransferStage::Idle {
+ self.consensus.begin_state_transfer_await();
+ }
+ }
+ }
+
+ pub async fn on_persistence_completed(&mut self, completion: PersistenceCompletion) {
+ if !self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.accepts_completion(completion))
+ {
+ return;
+ }
+ self.drive_persistence().await;
+ }
+
+ pub fn needs_persistence_checkpoint(&self) -> bool {
+ self.persistence.as_ref().is_some_and(|persistence| {
+ persistence.needs_checkpoint()
+ && self.consensus.commit_min().min(persistence.head()) > persistence.checkpoint_op()
+ })
+ }
+
+ pub async fn checkpoint_persistence(&mut self, config: &PartitionsConfig) {
+ if self.fatal.is_some() {
+ return;
+ }
+ let Some(persistence) = self
+ .persistence
+ .as_ref()
+ .filter(|persistence| persistence.needs_checkpoint())
+ .cloned()
+ else {
+ return;
+ };
+ let through_op = self.consensus.commit_min().min(persistence.head());
+ if through_op <= persistence.checkpoint_op() {
+ return;
+ }
+ match self.commit_messages_inner(config, true, through_op).await {
+ Ok(true) => {}
+ Ok(false) => return,
+ Err(error) => {
+ error!(%error, namespace_raw = self.namespace().inner(), "partition checkpoint failed");
+ self.fatal = Some(FatalCommit {
+ namespace_raw: self.namespace().inner(),
+ op: through_op,
+ operation: Operation::SendMessages,
+ });
+ return;
+ }
+ }
+ let (files, directories) = self.persistence_checkpoint_files(config);
+ persistence.checkpoint_files(through_op, files, directories);
+ self.start_persistence();
+ }
+
+ fn persistence_checkpoint_files(
+ &self,
+ config: &PartitionsConfig,
+ ) -> (Vec<std::path::PathBuf>, Vec<std::path::PathBuf>) {
+ let namespace = self.namespace();
+ let Some(persistence) = &self.persistence else {
+ return (Vec::new(), Vec::new());
+ };
+ let (segments, offsets) = persistence.take_dirty_files();
+ let mut paths = Vec::with_capacity(
+ segments.len() * 2
+ + offsets
+ .iter()
+ .map(std::collections::BTreeSet::len)
+ .sum::<usize>(),
+ );
+ for start_offset in segments {
+ // Retention may already have removed a dirty sealed segment.
+ if self
+ .log
+ .segments()
+ .binary_search_by_key(&start_offset, |segment| segment.start_offset)
+ .is_err()
+ {
+ continue;
+ }
+ paths.push(config.get_messages_path(
+ namespace.stream_id(),
+ namespace.topic_id(),
+ namespace.partition_id(),
+ start_offset,
+ ));
+ paths.push(config.get_index_path(
+ namespace.stream_id(),
+ namespace.topic_id(),
+ namespace.partition_id(),
+ start_offset,
+ ));
+ }
+ for (kind, consumers) in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup]
+ .into_iter()
+ .zip(offsets)
+ {
+ for consumer_id in consumers {
+ if self.durable_consumer_offsets.contains(kind, consumer_id)
+ && let Some(path) = self.persisted_offset_path(kind, consumer_id)
+ {
+ paths.push(path);
+ }
+ }
+ }
+ let mut directories = Vec::with_capacity(4);
+ for directory in [
+ &self.consumer_offsets_path,
+ &self.consumer_group_offsets_path,
+ ]
+ .into_iter()
+ .flatten()
+ {
+ let path = std::path::PathBuf::from(directory);
+ directories.push(path.clone());
+ if let Some(parent) = path.parent()
+ && !directories.iter().any(|existing| existing == parent)
+ {
+ directories.push(parent.to_path_buf());
+ }
+ }
+ if let Some(directory) = &self.partition_dir {
+ directories.push(std::path::PathBuf::from(directory));
+ }
+ (
+ paths.into_iter().map(std::path::PathBuf::from).collect(),
+ directories,
+ )
+ }
+
+ pub fn take_persistence_metrics(&self) -> Option<crate::persistence::PersistenceMetrics> {
+ self.persistence
+ .as_ref()
+ .map(|persistence| persistence.take_metrics())
+ }
+
+ fn persistence_checkpoint_pending(&self) -> bool {
+ self.persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.checkpoint_pending())
+ }
+
+ pub async fn drive_persistence(&mut self) {
+ if self.fatal.is_some() {
+ return;
+ }
+ let Some(persistence) = self.persistence.as_ref() else {
+ return;
+ };
+ if let Some(error) = persistence.failure() {
+ error!(%error, namespace_raw = self.namespace().inner(), "partition prepare persistence failed");
+ if self.fatal.is_none() {
+ self.fatal = Some(FatalCommit {
+ namespace_raw: self.namespace().inner(),
+ op: persistence.head(),
+ operation: persistence.failure_operation(),
+ });
+ }
+ return;
+ }
+ if self.materialization_missing || !self.ensure_wal_view() {
+ return;
+ }
+ // The shard's bounded pre-pass owns superblock I/O across partitions.
+ if self.superblock.is_some() && self.consensus.needs_superblock_persist() {
+ return;
+ }
+ let durable_op = persistence.durable_op();
+ loop {
+ let pending = self
+ .pending_persisted_acks
+ .borrow()
+ .first_key_value()
+ .filter(|(op, _)| **op <= durable_op)
+ .map(|(_, header)| *header);
+ let Some(header) = pending else {
+ break;
+ };
+ if persistence.checksum(header.op) == Some(header.checksum)
+ && !self.send_prepare_ok(&header).await
+ {
+ break;
+ }
+ self.pending_persisted_acks.borrow_mut().remove(&header.op);
+ }
+ }
+
+ pub async fn acknowledge_prepare(&self, op: u64) {
+ let Some(prepare) = self.log.journal().inner.repair_entry(op) else {
+ return;
+ };
+ let Ok(header) = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &prepare.as_slice()[..size_of::<PrepareHeader>()],
+ ) else {
+ return;
+ };
+ let header = *header;
+ self.persist_repaired_prefix();
+ self.send_prepare_ok(&header).await;
+ }
+
+ const fn requires_persistence(&self, operation: Operation) -> bool {
+ match operation {
+ Operation::SendMessages => self.durability().is_persisted(),
+ Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset => {
+ self.consumer_offset_durability().is_persisted()
+ }
+ _ => false,
+ }
+ }
+
+ fn ensure_wal_view(&self) -> bool {
+ let Some(persistence) = &self.persistence else {
+ return true;
+ };
+ if persistence.certified_log_view() == Some(self.consensus.log_view()) {
+ return true;
+ }
+ if self.materialization_missing || !self.consensus.is_normal() {
+ return false;
+ }
+ self.persist_repaired_prefix();
+ let ready = persistence.certify_log_view(
+ self.consensus.log_view(),
+ self.consensus.sequencer().current_sequence(),
+ self.consensus.last_prepare_checksum(),
+ );
+ self.start_persistence();
+ ready
+ }
+
+ pub fn register_rebuilt_ack(&self, header: &PrepareHeader) -> bool {
+ let durable = !self.requires_persistence(header.operation)
+ || self.persistence.as_ref().is_some_and(|persistence| {
+ persistence.is_durable(header)
+ && persistence.certified_log_view() == Some(self.consensus.log_view())
+ });
+ if !durable {
+ self.pending_persisted_acks
+ .borrow_mut()
+ .insert(header.op, *header);
+ }
+ durable
+ }
+
+ fn submit_prepare_persistence(&self, prepare: Frozen<4096>, operation: Operation) -> bool {
+ let Some(persistence) = &self.persistence else {
+ return self.consensus.replica_count() == 1 || !self.requires_persistence(operation);
+ };
+ // A previous admission may have stopped at capacity. Preserve the
+ // ordered prefix before submitting a newer forwarded prepare.
+ let op = bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &prepare.as_slice()[..size_of::<PrepareHeader>()],
+ )
+ .map_or(u64::MAX, |header| header.op);
+ if op > persistence.head().saturating_add(1) {
+ self.persist_repaired_prefix_through(op.saturating_sub(1));
+ if op > persistence.head().saturating_add(1) {
+ return false;
+ }
+ }
+ if let Err(error) = persistence.append(prepare, self.requires_persistence(operation)) {
+ warn!(%error, namespace_raw = self.namespace().inner(), "partition WAL refused prepare");
+ if error.kind() != std::io::ErrorKind::WouldBlock {
+ persistence.fail(error);
+ }
+ return false;
+ }
+ self.start_persistence();
+ true
+ }
+
+ fn persist_repaired_prefix(&self) {
+ self.persist_repaired_prefix_through(u64::MAX);
+ }
+
+ fn persist_repaired_prefix_through(&self, through: u64) {
+ let Some(persistence) = &self.persistence else {
+ return;
+ };
+ while let Some(op) = persistence.head().checked_add(1) {
+ if op > through || op > self.consensus.sequencer().current_sequence() {
+ break;
+ }
+ let Some(prepare) = self.log.journal().inner.repair_entry(op) else {
+ break;
+ };
+ if let Err(error) = journal::partition_journal::record_length(prepare.len()) {
+ persistence.fail(error);
+ break;
+ }
+ if !persistence.has_capacity(prepare.len()) {
+ break;
+ }
+ // A completed repair may immediately vote in a new view.
+ if let Err(error) = persistence.append(prepare, true) {
+ warn!(%error, op, "cannot persist repaired partition history");
+ if error.kind() != std::io::ErrorKind::WouldBlock {
+ persistence.fail(error);
+ }
+ break;
+ }
+ }
+ self.start_persistence();
+ }
+
+ pub(crate) fn start_persistence(&self) {
+ if let Some(persistence) = &self.persistence
+ && persistence.start()
+ {
+ self.consensus
+ .message_bus()
+ .spawn(Rc::clone(persistence).run());
+ }
+ }
+
pub fn set_partition_dir(&mut self, partition_dir: String) {
self.partition_dir = Some(partition_dir);
+ self.segment_names_dirty.set(true);
}
/// Attach the durable superblock store the boot path opened for this
@@ -728,6 +1260,7 @@
/// As a separate call it was silently optional, and one of the three attach
/// sites dropped it.
pub fn set_superblock(&mut self, superblock: Rc<SB>, recovered: Option<&consensus::VsrState>) {
+ self.recovered_log_view = recovered.map(|state| state.log_view);
self.superblock = Some(superblock);
self.durable_offset_frontier
.set(recovered.map_or(0, |state| state.offset_frontier));
@@ -757,6 +1290,9 @@
#[allow(clippy::future_not_send)]
#[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
pub async fn persist_superblock_if_needed(&self) -> bool {
+ if !self.materialization_missing && !self.ensure_wal_view() {
+ return false;
+ }
let Some(superblock) = self.superblock.as_ref() else {
// No store (in-memory / simulated partitions): nothing can be
// recorded, so keep the durable cells current instead. The
@@ -1940,21 +2476,21 @@
self.runtime_options
}
- /// Segment size this partition rolls at: the per-topic value when the
- /// topic was created with one, else the shard-wide configured size.
+ /// Segment size used consistently by live writes, recovery, and transfer.
#[must_use]
- pub fn effective_segment_size(&self, config: &PartitionsConfig) -> IggyByteSize {
- self.runtime_options
- .segment_size
- .unwrap_or(config.segment_size)
+ pub fn effective_segment_size(&self) -> IggyByteSize {
+ self.runtime_options.effective_segment_size()
}
- /// Whether this partition's writes fsync.
+ /// The stable-storage requirement for message acknowledgment.
#[must_use]
- pub fn effective_enforce_fsync(&self, config: &PartitionsConfig) -> bool {
- self.runtime_options
- .enforce_fsync
- .unwrap_or(config.enforce_fsync)
+ pub const fn durability(&self) -> iggy_common::Durability {
+ self.runtime_options.durability
+ }
+
+ #[must_use]
+ pub const fn consumer_offset_durability(&self) -> iggy_common::Durability {
+ self.runtime_options.consumer_offset_durability
}
/// Message-count threshold that flushes this partition's journal.
@@ -2000,13 +2536,11 @@
consumer_group_offsets_path: String,
consumer_offsets: ConsumerOffsets,
consumer_group_offsets: ConsumerGroupOffsets,
- consumer_offset_enforce_fsync: bool,
) {
self.consumer_offsets = Arc::new(consumer_offsets);
self.consumer_group_offsets = Arc::new(consumer_group_offsets);
self.consumer_offsets_path = Some(consumer_offsets_path);
self.consumer_group_offsets_path = Some(consumer_group_offsets_path);
- self.consumer_offset_enforce_fsync = consumer_offset_enforce_fsync;
}
/// Seed committed membership from one recovered offset file. The logical
@@ -2107,10 +2641,8 @@
&mut self,
op: u64,
) -> Result<(), IggyError> {
- // Peek (copy) instead of remove: if `persist_consumer_offset_commit`
- // fails (e.g. disk full, fd exhausted) the pending entry must remain
- // stageable for retry on the next apply. Removing first would strand
- // the op - not on disk AND not in memory.
+ // Keep the staged cursor until materialization succeeds. The commit
+ // walk fences a failed apply, which must not look like a completed op.
let pending = match self.pending_consumer_offset_commits.get(&op) {
Some(pending) => *pending,
// A view change clears the staged table (uncommitted ops may be
@@ -2126,6 +2658,13 @@
// durably stored; the in-memory update is idempotent on replay
// because we look up by (kind, id).
self.persist_consumer_offset_commit(pending).await?;
+ if let Some(persistence) = &self.persistence {
+ persistence.mark_offset_dirty(
+ crate::state_transfer::consumer_kind_index(pending.kind),
+ pending.consumer_id,
+ matches!(pending.mutation, PendingConsumerOffsetMutation::Upsert(_)),
+ );
+ }
let operation = match pending.mutation {
PendingConsumerOffsetMutation::Upsert(_) => Operation::StoreConsumerOffset,
PendingConsumerOffsetMutation::Delete => Operation::DeleteConsumerOffset,
@@ -2139,10 +2678,57 @@
Ok(())
}
+ async fn write_consumer_offset(
+ &self,
+ path: &str,
+ offset: u64,
+ persisted: bool,
+ ) -> Result<(), IggyError> {
+ if let Some(persistence) = &self.persistence {
+ let (result, file) = crate::offset_storage::persist_offset_retained(
+ path,
+ offset,
+ persistence.take_offset_file(path),
+ )
+ .await?;
+ let result = result.and(persistence.retain_offset_file(path.to_owned(), file).await);
+ result.map_err(|error| {
+ // Unlike WAL admission, a failed write can leave a partial record.
+ persistence.fail_operation(error, Operation::StoreConsumerOffset);
+ IggyError::CannotWriteToFile
+ })
+ } else {
+ persist_offset(path, offset, persisted).await
+ }
+ }
+
+ async fn write_cold_consumer_offset(
+ &self,
+ path: &str,
+ offset: u64,
+ persisted: bool,
+ ) -> Result<(u64, bool), IggyError> {
+ if self.persistence.is_some() {
+ let result = crate::offset_storage::read_offset_max(path, offset).await?;
+ if result.written {
+ self.write_consumer_offset(path, result.offset, false)
+ .await?;
+ }
+ Ok((result.offset, result.written))
+ } else {
+ let result = persist_offset_max(path, offset, persisted).await?;
+ Ok((result.offset, result.written))
+ }
+ }
+
async fn persist_consumer_offset_commit(
&self,
pending: PendingConsumerOffsetCommit,
) -> Result<(), IggyError> {
+ // For either offset policy, the WAL protects these updates until checkpoint
+ // syncs their retained writers and directories before reclaiming history.
+ let persisted =
+ self.consumer_offset_durability().is_persisted() && self.persistence.is_none();
let path = self.persisted_offset_path(pending.kind, pending.consumer_id);
let capacity = self.consumer_offset_capacity_for(pending.kind);
match pending.mutation {
@@ -2167,14 +2753,12 @@
}
(Some(path), Some(state)) => {
let value = state.committed_offset.max(offset);
- persist_offset(path, value, self.consumer_offset_enforce_fsync).await?;
+ self.write_consumer_offset(path, value, persisted).await?;
(value, true)
}
(Some(path), None) => {
- let persisted =
- persist_offset_max(path, offset, self.consumer_offset_enforce_fsync)
- .await?;
- (persisted.offset, persisted.written)
+ self.write_cold_consumer_offset(path, offset, persisted)
+ .await?
}
};
self.durable_consumer_offsets.record_auto_commit(
@@ -2187,7 +2771,7 @@
},
persisted_high_water,
);
- if written && self.consumer_offset_enforce_fsync {
+ if written && persisted {
self.mark_consumer_offset_dir_dirty(pending.kind);
}
capacity.clear_stranded(pending.consumer_id);
@@ -2198,7 +2782,7 @@
}
PendingConsumerOffsetMutation::Upsert(offset) => {
if let Some(path) = path.as_deref() {
- persist_offset(path, offset, self.consumer_offset_enforce_fsync).await?;
+ self.write_consumer_offset(path, offset, persisted).await?;
}
let created = self.durable_consumer_offsets.record_explicit(
pending.kind,
@@ -2206,7 +2790,7 @@
offset,
offset,
);
- if path.is_some() && self.consumer_offset_enforce_fsync {
+ if path.is_some() && persisted {
self.mark_consumer_offset_dir_dirty(pending.kind);
}
capacity.clear_stranded(pending.consumer_id);
@@ -2222,7 +2806,10 @@
// unsuccessful unlink would let boot resurrect the key.
match delete_persisted_offset(path).await {
Ok(removed) => {
- if removed && self.consumer_offset_enforce_fsync {
+ if let Some(persistence) = &self.persistence {
+ persistence.retire_offset_file(path);
+ }
+ if removed && persisted {
self.mark_consumer_offset_dir_dirty(pending.kind);
}
capacity.clear_stranded(pending.consumer_id);
@@ -3220,6 +3807,16 @@
}
}
+ pub(crate) const fn fence_install_failure(&mut self, op: u64) {
+ if self.fatal.is_none() {
+ self.fatal = Some(FatalCommit {
+ namespace_raw: self.consensus.group(),
+ op,
+ operation: Operation::SendMessages,
+ });
+ }
+ }
+
fn partition_dir(&self) -> Option<String> {
if self.partition_dir.is_some() {
return self.partition_dir.clone();
@@ -3403,7 +4000,11 @@
// otherwise turn a routing artifact into a terminal 404 or 400.
// Reject it first with the only response that proves the request
// was never admitted, so the caller may safely retry elsewhere.
- if consensus.is_follower() || !consensus.is_normal() || consensus.is_transferring() {
+ if self.materialization_missing
+ || consensus.is_follower()
+ || !consensus.is_normal()
+ || consensus.is_transferring()
+ {
emit_partition_diag(
tracing::Level::WARN,
&PartitionDiagEvent::new(
@@ -3423,6 +4024,27 @@
return;
}
+ let frame_bytes = message.as_slice().len();
+ if self.persistence.is_some()
+ && frame_bytes > journal::partition_journal::PREPARE_BYTES_MAX
+ {
+ let error = IggyError::InvalidMessagesSize(
+ u32::try_from(frame_bytes).unwrap_or(u32::MAX),
+ u32::try_from(journal::partition_journal::PREPARE_BYTES_MAX)
+ .expect("prepare limit fits wire size"),
+ );
+ warn!(%error, namespace_raw = self.namespace().inner(), "persisted topic rejected oversized prepare");
+ Self::send_partition_deny_or_log(
+ consensus,
+ message.header(),
+ error.as_code(),
+ "oversized prepare rejection failed",
+ reply.take(),
+ )
+ .await;
+ return;
+ }
+
// Parse once for both the delete-existence check and AckLevel dispatch.
let consumer_offset = match message.header().operation {
Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset => {
@@ -3506,6 +4128,22 @@
}
}
+ if self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| !persistence.has_capacity(message.as_slice().len()))
+ {
+ Self::send_partition_deny_or_log(
+ consensus,
+ message.header(),
+ IggyError::TransientNotAccepted.as_code(),
+ "partition WAL backpressure reply failed",
+ reply.take(),
+ )
+ .await;
+ return;
+ }
+
if matches!(message.header().operation, Operation::DeleteConsumerOffset)
&& let Some((kind, consumer_id, _, _)) = consumer_offset
&& let Err(error) = self.ensure_consumer_offset_exists(kind, consumer_id)
@@ -3964,7 +4602,16 @@
);
return;
};
+ let prepare_to_persist = self
+ .persistence
+ .as_ref()
+ .map(|_| frozen_for_forward.clone());
let consensus = self.consensus();
+ if consensus.is_follower() && header.op > consensus.sequencer().current_sequence() {
+ consensus.sequencer().set_sequence(header.op);
+ consensus.set_last_prepare_checksum(header.checksum);
+ consensus.observe_prepare_timestamp(header.timestamp);
+ }
if let Err(error) =
replicate_frozen_to_next_in_chain(consensus, frozen_for_forward).await
{
@@ -3987,24 +4634,39 @@
return;
}
}
+ if let Some(prepare) = prepare_to_persist
+ && !self.submit_prepare_persistence(prepare, header.operation)
+ {
+ return;
+ }
self.send_prepare_ok(&header).await;
return;
}
- // Backup gap check; primary sequencer pre-advanced by
- // push_prepare_entry.
- //
- // The sequencer, deliberately, where `metadata::on_replicate` gates on its
- // journal. The two frontiers cannot drift apart on this plane:
- // `install_state_transfer` rewinds the sequencer to the offer's `commit_op`
- // (where the metadata install moves a durable snapshot floor and leaves the
- // WAL head behind it), and the repair ingest advances it by walking the
- // journal. Reading the journal here would answer 0 after every restart,
- // since this plane's journal is memory-only and starts empty however much
- // data sits on disk.
+ // A header-only StartView can announce bodies this backup still lacks.
+ // Live same-view retransmits may fill its next verified slot; repair
+ // frames above commit still require the elected canonical headers.
let is_backup = self.consensus().is_follower();
if is_backup {
- if header.op != current_op + 1 {
+ let fills_announced_gap = header.op <= current_op
+ && header.op > self.purge_floor_op
+ && !self.consensus().view_log_is_pending()
+ && self
+ .log
+ .journal()
+ .inner
+ .repaired_window_shape(self.consensus().commit_min(), header.op - 1)
+ .complete
+ && if header.op == 1 {
+ header.parent == 0
+ } else {
+ self.log
+ .journal()
+ .inner
+ .repair_header(header.op - 1)
+ .is_some_and(|previous| previous.checksum == header.parent)
+ };
+ if header.op != current_op + 1 && !fills_announced_gap {
// `sequence` is what separates the two shapes this line covers:
// a forward gap (op above the sequencer, the hole the repair
// driver closes) and a retransmit of an op this replica already
@@ -4143,6 +4805,10 @@
}
};
+ let prepare_to_persist = self
+ .persistence
+ .as_ref()
+ .map(|_| frozen_for_forward.clone());
let consensus = self.consensus();
// Backup only: advance sequencer + checksum after journal append.
// Pre-advance on failing apply would leave consensus claiming op N
@@ -4152,8 +4818,11 @@
// sibling request pipelined during the apply await would otherwise be
// rewound to a stale op + parent, projecting a duplicate next.
if is_backup {
- consensus.sequencer().set_sequence(header.op);
- consensus.set_last_prepare_checksum(header.checksum);
+ // Filling a lower slot must not rewind the announced head or parent.
+ if header.op >= current_op {
+ consensus.sequencer().set_sequence(header.op);
+ consensus.set_last_prepare_checksum(header.checksum);
+ }
consensus.observe_prepare_timestamp(header.timestamp);
}
if let Err(error) = replicate_frozen_to_next_in_chain(consensus, frozen_for_forward).await {
@@ -4187,6 +4856,11 @@
);
}
+ if let Some(prepare) = prepare_to_persist
+ && !self.submit_prepare_persistence(prepare, header.operation)
+ {
+ return;
+ }
self.send_prepare_ok(&header).await;
}
@@ -4229,8 +4903,13 @@
if !ack_quorum_reached(self.consensus(), PlaneKind::Partitions, &header) {
return;
}
+ // Keep the earned quorum and reply slots in the pipeline while the
+ // checkpoint worker synchronizes this partition's materialized files.
+ if self.persistence_checkpoint_pending() {
+ return;
+ }
- let drained = drain_committable_prefix(self.consensus());
+ let drained = self.drain_persistable_commits(config);
if drained.is_empty() {
return;
}
@@ -4267,7 +4946,10 @@
/// replica ask its peers for it.
#[allow(clippy::future_not_send)]
pub async fn commit_journal(&mut self, config: &PartitionsConfig) {
- if self.fatal.is_some() {
+ if self.fatal.is_some()
+ || self.materialization_missing
+ || self.persistence_checkpoint_pending()
+ {
return;
}
self.resynchronize_consumer_offset_reservations();
@@ -4281,15 +4963,17 @@
// tail still finds its headers here (no wedge). Pipeline-first keeps a
// freshly promoted primary (rebuilt pipeline) draining there, avoiding a
// double-count against `advance_commit_min`.
- let mut drained = drain_committable_prefix(self.consensus());
+ let mut drained = self.drain_persistable_commits(config);
+ let send_client_replies = !drained.is_empty() && self.consensus.is_primary();
if drained.is_empty() {
- drained = self.collect_committable_from_journal(COMMIT_WALK_OPS_MAX);
+ drained = self.collect_committable_from_journal(COMMIT_WALK_OPS_MAX, config);
}
if drained.is_empty() {
return;
}
- self.handle_committed_entries(drained, config, false).await;
+ self.handle_committed_entries(drained, config, send_client_replies)
+ .await;
{
let consensus = self.consensus();
emit_namespace_progress_event(
@@ -4301,6 +4985,60 @@
}
}
+ fn drain_persistable_commits(&self, config: &PartitionsConfig) -> Vec<PipelineEntry> {
+ let Some(persistence) = &self.persistence else {
+ return drain_committable_prefix(self.consensus());
+ };
+ self.persist_repaired_prefix();
+ let through = self.consensus.commit_max().min(persistence.head());
+ let materialize = self.should_persist_messages(config);
+ let mut drained = Vec::new();
+ self.consensus.with_pipeline_mut(|pipeline| {
+ let mut next = self.consensus.commit_min() + 1;
+ while let Some(entry) = pipeline.head() {
+ if entry.header.op > through {
+ break;
+ }
+ if entry.header.op != next {
+ report_uncommittable_head(
+ self.consensus.replica(),
+ entry.header.op,
+ self.consensus.commit_min(),
+ self.consensus.commit_max(),
+ drained.len(),
+ );
+ break;
+ }
+ if !self.prepare_body_is_ready(&entry.header, materialize) {
+ break;
+ }
+ drained.push(pipeline.pop().expect("pipeline head exists"));
+ next += 1;
+ }
+ });
+ if !drained.is_empty() {
+ self.consensus.sync_prepare_timeout();
+ }
+ drained
+ }
+
+ fn prepare_body_is_ready(&self, header: &PrepareHeader, materialize: bool) -> bool {
+ header.operation != Operation::SendMessages
+ || header.op <= self.purge_floor_op
+ || (!self.durability().is_persisted() && !materialize)
+ || self
+ .persistence
+ .as_ref()
+ .filter(|persistence| persistence.segment_checkpoint().is_some())
+ .is_none_or(|persistence| {
+ if self.durability().is_persisted() {
+ persistence.is_durable(header)
+ } else {
+ persistence.is_written(header)
+ }
+ })
+ }
+
/// Committable entries (ops `commit_min+1 ..= commit_max`) read from the
/// journal, for a backup whose pipeline is empty. Stops at the first missing
/// op: a replication gap must not be skipped, or `advance_commit_min`'s
@@ -4315,7 +5053,11 @@
/// directly, but not as a one-line swap: the apply path needs batch bytes and
/// the ring is capacity-bounded, so headers it cannot back with bytes would
/// fence the partition instead of stalling it.
- fn collect_committable_from_journal(&self, max_ops: usize) -> Vec<PipelineEntry> {
+ fn collect_committable_from_journal(
+ &self,
+ max_ops: usize,
+ config: &PartitionsConfig,
+ ) -> Vec<PipelineEntry> {
let from_op = self.consensus.commit_min() + 1;
// Stop below the pipeline head. The drain above holds rather than pops
// when the head is not the op owed next; walking that op out of the
@@ -4326,17 +5068,22 @@
// Only a head at or above `from_op` lowers the ceiling: an absent head is
// a backup's empty pipeline, and a lower head is already stranded and must
// not freeze the walk on top of that.
- let commit_max = self.consensus.commit_max();
+ let commit_max = self.persistence.as_ref().map_or_else(
+ || self.consensus.commit_max(),
+ |persistence| self.consensus.commit_max().min(persistence.head()),
+ );
let commit_max = self
.consensus
.pipeline_head_header()
.filter(|head| head.op >= from_op)
.map_or(commit_max, |head| commit_max.min(head.op - 1));
+ let materialize = self.should_persist_messages(config);
self.log
.journal()
.inner
.committed_headers_from(from_op, commit_max, max_ops)
.into_iter()
+ .take_while(|header| self.prepare_body_is_ready(header, materialize))
.map(PipelineEntry::new)
.collect()
}
@@ -4615,6 +5362,14 @@
let restored_position = active_size
.checked_add(retained_info.size.as_bytes_u64())
.ok_or(IggyError::CannotAppendMessage)?;
+ if let Some(persistence) = &self.persistence {
+ persistence.truncate_from(from_op);
+ self.start_persistence();
+ persistence
+ .drain_with_timeout()
+ .await
+ .map_err(|_| IggyError::CannotSyncFile)?;
+ }
let removed = self
.log
.journal()
@@ -4647,12 +5402,21 @@
Ok(removed)
}
- async fn commit_messages(&mut self, config: &PartitionsConfig) -> Result<(), IggyError> {
+ async fn commit_messages(
+ &mut self,
+ config: &PartitionsConfig,
+ through_op: u64,
+ ) -> Result<bool, IggyError> {
#[cfg(any(test, feature = "fault-injection"))]
if std::mem::take(&mut self.injected_commit_failure) {
return Err(IggyError::CannotSaveMessagesToSegment);
}
- self.commit_messages_inner(config, false).await
+ self.commit_messages_inner(
+ config,
+ self.consensus.replica_count() == 1 && self.durability().is_persisted(),
+ through_op,
+ )
+ .await
}
/// Flush the committed journal prefix to segment storage regardless of
@@ -4671,15 +5435,47 @@
&mut self,
config: &PartitionsConfig,
) -> Result<(), IggyError> {
- self.commit_messages_inner(config, true).await
+ if let Some(persistence) = self
+ .persistence
+ .as_ref()
+ .filter(|persistence| persistence.segment_checkpoint().is_some())
+ {
+ self.start_persistence();
+ // Shutdown and transfer must finish the flush, but never drain while
+ // holding the lock used to append new messages.
+ persistence.drain_with_timeout().await.map_err(|error| {
+ warn!(%error, "cannot flush committed partition messages");
+ IggyError::CannotSyncFile
+ })?;
+ }
+ if self
+ .commit_messages_inner(config, true, self.consensus.commit_max())
+ .await?
+ {
+ Ok(())
+ } else {
+ Err(IggyError::CannotSyncFile)
+ }
}
+ fn should_persist_messages(&self, config: &PartitionsConfig) -> bool {
+ let journal_info = self.log.journal().info;
+ // The existing thresholds include both committed and uncommitted batches.
+ journal_info.messages_count > 0
+ && (self.log.active_segment().is_full()
+ || journal_info.messages_count >= self.effective_messages_required_to_save(config)
+ || journal_info.size.as_bytes_u64()
+ >= self.effective_size_of_messages_required_to_save(config))
+ }
+
+ /// Returns false while the requested physical prefix is still pending in the WAL.
#[allow(clippy::too_many_lines)]
async fn commit_messages_inner(
&mut self,
config: &PartitionsConfig,
force: bool,
- ) -> Result<(), IggyError> {
+ through_op: u64,
+ ) -> Result<bool, IggyError> {
let write_lock = self.write_lock.clone();
let _guard = write_lock.lock().await;
@@ -4692,23 +5488,11 @@
"forced flush: journal counts zero messages, nothing to persist"
);
}
- return Ok(());
+ return Ok(true);
}
- // `journal_info` counts the committed prefix PLUS the uncommitted tail
- // still resident in the journal, yet only the committed prefix is
- // flushed below. With `messages_required_to_save > 1` the tail bytes
- // count toward the trigger, so this threshold is not "committed bytes
- // only" - safe, since the flush still writes only committed bytes.
- let is_full = self.log.active_segment().is_full();
- let unsaved_messages_count_exceeded =
- journal_info.messages_count >= self.effective_messages_required_to_save(config);
- let unsaved_messages_size_exceeded = journal_info.size.as_bytes_u64()
- >= self.effective_size_of_messages_required_to_save(config);
- let should_persist =
- is_full || unsaved_messages_count_exceeded || unsaved_messages_size_exceeded;
- if !force && !should_persist {
- return Ok(());
+ if !force && !self.should_persist_messages(config) {
+ return Ok(true);
}
// Read (do NOT yet evict) ONLY the committed prefix (op <= commit_max,
@@ -4723,7 +5507,7 @@
// the commit path panics the shard pump instead. All segment range /
// stats / durable-offset accounting below is computed from the committed
// entries, not the resident-journal snapshot above.
- let commit_max = self.consensus.commit_max();
+ let commit_max = self.consensus.commit_max().min(through_op);
let committed_entries = self.log.journal().inner.committed_prefix(commit_max);
if committed_entries.is_empty() {
if force {
@@ -4735,7 +5519,35 @@
"forced flush: no committed entries resident"
);
}
- return Ok(());
+ return Ok(true);
+ }
+ if let Some(persistence) = self
+ .persistence
+ .as_ref()
+ .filter(|persistence| persistence.segment_checkpoint().is_some())
+ {
+ if persistence.failure().is_some() {
+ return Err(IggyError::CannotSyncFile);
+ }
+ // Check the entire flush before evicting any chunk. A partial pending
+ // flush could evict headers that the commit walk still needs to retry.
+ if committed_entries
+ .iter()
+ .rev()
+ .find(|entry| {
+ peek_operation(entry) == Operation::SendMessages
+ && peek_op(entry) > self.purge_floor_op
+ })
+ .is_some_and(|entry| {
+ if self.durability().is_persisted() {
+ !persistence.is_durable_through(peek_op(entry))
+ } else {
+ !persistence.is_written_through(peek_op(entry))
+ }
+ })
+ {
+ return Ok(false);
+ }
}
// Persist the prefix in segment-sized chunks: a segment seals on the
// first flush whose committed bytes reach OR EXCEED `max_size`, no
@@ -4907,6 +5719,9 @@
self.evict_committed_prefix(evictable).await;
return Err(error);
}
+ if let Some(persistence) = &self.persistence {
+ persistence.mark_segment_dirty(self.log.active_segment().start_offset);
+ }
// Insert the flushed sparse-index entry into the in-mem cache only now
// that the batch + index are durable. Inserting in the build loop (before
// persist) re-inserts a duplicate on the next flush after a persist
@@ -4955,7 +5770,7 @@
self.offset.store(durable_offset, Ordering::Release);
self.stats.set_current_offset(durable_offset);
}
- Ok(())
+ Ok(true)
}
/// Evict the committed prefix (the `count` front entries read by
@@ -5000,6 +5815,9 @@
) {
let replica_id = self.consensus.replica();
let namespace_raw = self.consensus.group();
+ let Some(through_op) = drained.last().map(|entry| entry.header.op) else {
+ return;
+ };
let drained_count = drained.len();
if let (Some(first), Some(last)) = (drained.first(), drained.last()) {
debug!(
@@ -5020,7 +5838,14 @@
// ring at all. A miss degrades to a successful send carrying no
// confirmation, a legal answer no client can tell from a real one.
let committed_batch_stats = self.resolve_committed_visible_offsets(&drained);
- let mut messages_committed = false;
+ // Keep the threshold decision used before draining. An append during an
+ // offset write or lock wait must not require an unchecked body mid-walk.
+ let mut messages_committed = !self.durability().is_persisted()
+ && self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.segment_checkpoint().is_some())
+ && !self.should_persist_messages(config);
// Apply the drained batch before advancing any op because directory
// durability is shared by every delete in the batch. Until the sync
@@ -5040,9 +5865,13 @@
*batch_stats,
&mut failed_commit,
config,
+ through_op,
)
.await
{
+ if !failed_commit {
+ return;
+ }
// Local commit failed but cluster committed (op came from
// drain_committable_prefix). Replica diverged, can't serve
// reads.
@@ -5080,6 +5909,27 @@
}
}
+ if messages_committed
+ && self.consensus.replica_count() == 1
+ && self.durability().is_persisted()
+ && self.segment_names_dirty.get()
+ && let Some(directory) = &self.partition_dir
+ {
+ if let Err(error) = crate::state_transfer::fsync_dir(directory).await {
+ error!(%error, namespace_raw, "cannot publish persisted segment names");
+ self.fatal = Some(FatalCommit {
+ namespace_raw,
+ op: drained
+ .last()
+ .map_or_else(|| self.consensus.commit_min(), |entry| entry.header.op),
+ operation: Operation::SendMessages,
+ });
+ return;
+ }
+
+ self.segment_names_dirty.set(false);
+ }
+
// Commit replies and the applied frontier must follow directory
// durability. One sync per dirty kind covers the whole walk. A sync
// failure is attributed to an operation of that kind in this walk.
@@ -5273,6 +6123,17 @@
continue;
}
}
+ // The kind directory is itself an entry in offsets/. Its
+ // publication must survive before a local persisted offset reply.
+ if let Some(parent) = std::path::Path::new(dir)
+ .parent()
+ .and_then(std::path::Path::to_str)
+ && let Err(error) = crate::state_transfer::fsync_dir(parent).await
+ {
+ warn!(%error, path = parent, "consumer offset parent directory sync failed");
+ failed[index] = true;
+ continue;
+ }
#[cfg(test)]
self.offset_dir_sync_count
.set(self.offset_dir_sync_count.get() + 1);
@@ -5354,23 +6215,28 @@
batch_stats: Option<CommittedBatchStats>,
failed_commit: &mut bool,
config: &PartitionsConfig,
+ through_op: u64,
) -> bool {
match prepare_header.operation {
Operation::SendMessages => {
if !*messages_committed {
- if let Err(error) = self.commit_messages(config).await {
- *failed_commit = true;
- warn!(
- target: "iggy.partitions.diag",
- plane = "partitions",
- replica_id = self.consensus.replica(),
- namespace_raw = self.namespace().inner(),
- op = prepare_header.op,
- operation = ?prepare_header.operation,
- %error,
- "failed to commit partition messages"
- );
- return false;
+ match self.commit_messages(config, through_op).await {
+ Ok(true) => {}
+ Ok(false) => return false,
+ Err(error) => {
+ *failed_commit = true;
+ warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ replica_id = self.consensus.replica(),
+ namespace_raw = self.namespace().inner(),
+ op = prepare_header.op,
+ operation = ?prepare_header.operation,
+ %error,
+ "failed to commit partition messages"
+ );
+ return false;
+ }
}
*messages_committed = true;
}
@@ -5670,6 +6536,7 @@
true
}
+ #[allow(clippy::too_many_lines)]
async fn persist_frozen_batches_to_disk(
&mut self,
frozen_batches: Vec<Frozen<4096>>,
@@ -5684,6 +6551,45 @@
return Ok(());
}
+ if let Some(persistence) = self
+ .persistence
+ .as_ref()
+ .filter(|persistence| persistence.segment_checkpoint().is_some())
+ {
+ let segment = self.log.active_segment();
+ let saved = persistence
+ .validate_segment_prefix(
+ &frozen_batches,
+ segment.start_offset,
+ segment.size.as_bytes_u64(),
+ self.durability().is_persisted(),
+ )
+ .map_err(|error| {
+ warn!(%error, "cannot expose the segment prefix");
+ IggyError::CannotSyncFile
+ })?;
+ let index_writer = self
+ .log
+ .index_writers()
+ .last()
+ .and_then(|writer| writer.as_ref())
+ .ok_or(IggyError::CannotWriteToFile)?;
+ let saved_indexes = index_writer.save_indexes(index_bytes).await?;
+ index_writer.advance(saved_indexes);
+ if let Some(writer) = self
+ .log
+ .messages_writers()
+ .last()
+ .and_then(|writer| writer.as_ref())
+ {
+ writer.advance(saved);
+ }
+ let segment_index = self.log.segments().len() - 1;
+ let segment = &mut self.log.segments_mut()[segment_index];
+ segment.size = IggyByteSize::from(segment.size.as_bytes_u64() + saved);
+ return Ok(());
+ }
+
let stripped_batches: Vec<_> = frozen_batches
.into_iter()
.map(|batch| batch.slice(std::mem::size_of::<PrepareHeader>()..))
@@ -5722,7 +6628,7 @@
let index_writer = index_writer.expect("checked above");
// Both writes are in flight before either completes, so under
- // `enforce_fsync` the two fdatasync round trips overlap instead of
+ // persisted message durability, the two data syncs overlap instead of
// serializing. `join` never cancels a half, so no write is dropped
// mid-flight when the other one fails.
let (log_result, index_result) = futures::future::join(
@@ -5921,7 +6827,11 @@
///
/// Holds `write_lock` to serialize against the commit/rotate path, which
/// runs on the separate consensus-tick loop.
+ #[allow(clippy::too_many_lines)]
pub async fn remove_sealed_segments_up_to(&mut self, up_to_offset: u64) -> SegmentRemoval {
+ if self.persistence_checkpoint_pending() {
+ return SegmentRemoval::default();
+ }
let write_lock = self.write_lock.clone();
let _guard = write_lock.lock().await;
@@ -5941,6 +6851,13 @@
if idx == last_idx || !segment.sealed || segment.end_offset > up_to_offset {
break;
}
+ if let Some(persistence) = &self.persistence
+ && let Some(checkpoint) = persistence.segment_checkpoint()
+ && segment.end_offset >= checkpoint.next_offset
+ {
+ persistence.request_checkpoint();
+ break;
+ }
if let Some((barrier_offset, kind, consumer_id)) = barrier
&& segment.end_offset > barrier_offset
{
@@ -6033,14 +6950,9 @@
/// (see `rotate_segment`); falls back to the config-derived path for
/// in-memory partitions with no directory.
///
- /// Both files are opened through `SegmentStorage::new` with
- /// `file_exists = false`, which TRUNCATES them. That is load-bearing, not
- /// incidental: this offset may already have an `.index` on disk (a crash
- /// between the state-transfer install's index-rename and log-rename loops
- /// leaves final-name indexes with no logs, and the boot sweep only reaches
- /// the ones still orphaned at startup). The `partitions`-side writers with
- /// the same names do NOT truncate, so a recreate path that opened them
- /// directly would read index entries from a previous generation.
+ /// Recreate the index to discard entries from an interrupted install. A
+ /// WAL-owned log can already contain retained bodies and must not be truncated.
+ /// Without WAL ownership, both files are recreated from the empty boundary.
///
/// # Errors
/// If the segment's log / index file cannot be created.
@@ -6049,6 +6961,7 @@
config: &PartitionsConfig,
start_offset: u64,
) -> Result<(), IggyError> {
+ self.segment_names_dirty.set(true);
let namespace = self.namespace();
let (messages_path, index_path) = self.partition_dir().map_or_else(
|| {
@@ -6074,41 +6987,58 @@
)
},
);
- let segment_size = self.effective_segment_size(config);
- let enforce_fsync = self.effective_enforce_fsync(config);
+ let segment_size = self.effective_segment_size();
+ let persisted = self.durability().is_persisted();
let preallocate_segments = self.effective_preallocate_segments(config);
let segment = Segment::new(start_offset, segment_size);
- let storage = SegmentStorage::new(&messages_path, &index_path, 0, 0, false)
- .await
- .map_err(|_| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?;
- let messages_size_bytes = storage
- .messages_writer
- .as_ref()
- .ok_or_else(|| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?
- .size_counter();
- let messages_writer = Rc::new(
- MessagesWriter::new(
+ // Transfer can install the empty segment before its WAL reset enables
+ // body ownership. Its storage must already match the resulting layout.
+ let segment_bodies = self.persistence.is_some();
+ let storage = if segment_bodies {
+ SegmentStorage::with_read_only_messages(
&messages_path,
- messages_size_bytes,
- enforce_fsync,
+ &index_path,
+ 0,
false,
- preallocate_segments.then_some(segment_size),
+ preallocate_segments.then_some(segment_size.as_bytes_u64()),
)
.await
- .map_err(|_| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?,
- );
+ } else {
+ SegmentStorage::new(&messages_path, &index_path, 0, 0, false).await
+ }
+ .map_err(|_| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?;
+ let messages_writer = if segment_bodies {
+ None
+ } else {
+ let messages_size_bytes = storage
+ .messages_writer
+ .as_ref()
+ .ok_or_else(|| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?
+ .size_counter();
+ Some(Rc::new(
+ MessagesWriter::new(
+ &messages_path,
+ messages_size_bytes,
+ persisted,
+ false,
+ preallocate_segments.then_some(segment_size),
+ )
+ .await
+ .map_err(|_| IggyError::CannotCreateSegmentLogFile(messages_path.clone()))?,
+ ))
+ };
let index_size_bytes = storage
.index_writer
.as_ref()
.ok_or_else(|| IggyError::CannotCreateSegmentIndexFile(index_path.clone()))?
.size_counter();
let index_writer = Rc::new(
- IggyIndexWriter::new(&index_path, index_size_bytes, enforce_fsync, false)
+ IggyIndexWriter::new(&index_path, index_size_bytes, persisted, false)
.await
.map_err(|_| IggyError::CannotCreateSegmentIndexFile(index_path.clone()))?,
);
self.log
- .add_persisted_segment(segment, storage, Some(messages_writer), Some(index_writer));
+ .add_persisted_segment(segment, storage, messages_writer, Some(index_writer));
Ok(())
}
@@ -6403,6 +7333,15 @@
let namespace = self.namespace();
+ if let Some(persistence) = &self.persistence {
+ persistence.mark_purge(generation, self.consensus.sequencer().current_sequence());
+ self.start_persistence();
+ if let Err(error) = persistence.drain_with_timeout().await {
+ warn!(%error, "cannot persist partition purge marker");
+ self.purge_deferred = true;
+ return Err(PurgeError::FrontierNotRecorded);
+ }
+ }
self.record_purge_frontier_reset(generation).await?;
// The purge recreates segment files at the paths it unlinks below, so
@@ -6440,11 +7379,32 @@
%error,
"failed to unlink segment file during purge"
);
+ if self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.segment_checkpoint().is_some())
+ {
+ return Err(PurgeError::Unserviceable(IggyError::CannotWriteToFile));
+ }
}
}
}
}
+ if self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.segment_checkpoint().is_some())
+ && let Some(directory) = &self.partition_dir
+ {
+ crate::state_transfer::remove_public_segment_files(directory)
+ .await
+ .map_err(|error| {
+ warn!(%error, "cannot remove uncommitted segment files during purge");
+ PurgeError::Unserviceable(IggyError::CannotDeleteFile)
+ })?;
+ }
+
// An in-flight state transfer was pulling the PRE-purge state: its
// staged segments hold data this purge just deleted, and letting the
// session complete renames it back in -- durably, because the install
@@ -6578,6 +7538,9 @@
"purge could not remove a consumer offset file"
);
} else {
+ if let Some(persistence) = &self.persistence {
+ persistence.retire_offset_file(&path);
+ }
self.consumer_offset_capacity_for(kind)
.clear_stranded(consumer_id);
}
@@ -6724,6 +7687,9 @@
/// op is already committed cluster-wide; there is nobody to ack to). The
/// commit walk runs at `RepairDone`, after the floor is known.
pub async fn apply_repaired_prepare(&mut self, message: Message<PrepareHeader>) {
+ if self.materialization_missing {
+ return;
+ }
let header = *message.header();
let Some(session) = self.repair else {
return;
@@ -6794,6 +7760,7 @@
);
return;
}
+ self.persist_repaired_prefix();
// Advance the sequencer only along the CONTIGUOUS journaled
// frontier. DVC advertises `op = sequencer.current_sequence()` and
// elections pick the max, so bumping straight to a repaired op that
@@ -6907,6 +7874,9 @@
self.consensus().set_commit_floor(floor);
}
}
+ if let Some(conclusion) = self.repair_persistence_pending(session) {
+ return conclusion;
+ }
let before = self.consensus().commit_min();
self.commit_journal(config).await;
let commit_min = self.consensus().commit_min();
@@ -6956,6 +7926,26 @@
}
}
+ fn repair_persistence_pending(&mut self, session: RepairSession) -> Option<RepairConclusion> {
+ if let Some(persistence) = &self.persistence {
+ self.persist_repaired_prefix();
+ if session
+ .floor
+ .is_some_and(|floor| floor > persistence.head())
+ {
+ self.repair = None;
+ return Some(RepairConclusion::FloorRefused {
+ floor: session.floor.unwrap_or(0),
+ to_op: session.commit_to_op,
+ });
+ }
+ if !persistence.is_durable_through(session.fetch_to_op) {
+ return Some(RepairConclusion::InProgress);
+ }
+ }
+ None
+ }
+
/// Journal a repaired `SendMessages` prepare, preserving its embedded
/// batch stamps. A stored prepare was stamped by `append_messages` on
/// the serving replica BEFORE it was journaled, so its `base_offset` /
@@ -7049,15 +8039,27 @@
Ok(Some(base_offset))
}
- async fn send_prepare_ok(&self, header: &PrepareHeader) {
+ async fn send_prepare_ok(&self, header: &PrepareHeader) -> bool {
+ if self.fatal.is_some() || self.materialization_missing {
+ return false;
+ }
// Durable-before-send: a PrepareOk implies this replica's
// (view, log_view), so it must not leave until they are durable, or a
// crash could recover an older view than the one this ack helped
// commit in, losing a committed op. Mirrors the view-change dispatch
// gate; withhold on persist failure and let the primary's prepare
// retransmit re-drive the ack once a later persist succeeds.
+ if self.consensus.replica_count() > 1 && !self.register_rebuilt_ack(header) {
+ self.ensure_wal_view();
+ return false;
+ }
if !self.persist_superblock_if_needed().await {
- return;
+ if self.persistence.is_some() {
+ self.pending_persisted_acks
+ .borrow_mut()
+ .insert(header.op, *header);
+ }
+ return false;
}
// Same fail-closed shape for a purge this replica accepted but has not
// applied: its counter still names the pre-purge offset space, so an ack
@@ -7066,7 +8068,7 @@
// lands. Local commits still apply -- this fences the SEND, exactly as
// the durability gate above does.
if self.purge_deferred {
- return;
+ return false;
}
// `VsrAction::RetransmitPrepares` reads from `self.log.journal`.
// Both `SendMessages` (via `append_send_messages_to_journal`) and
@@ -7075,7 +8077,7 @@
// that reaches here is journal-backed and ACKs as durable.
// (`header_by_op` is a linear scan, so re-proving that here would
// put O(journal) on every ack; the call-order invariant stands in.)
- send_prepare_ok_common(self.consensus(), header, true).await;
+ send_prepare_ok_common(self.consensus(), header, true).await
}
}
@@ -7430,7 +8432,9 @@
use bytes::Bytes;
use compio::io::AsyncWriteAtExt;
use consensus::LocalPipeline;
- use iggy_binary_protocol::{Command, ReplyHeader, WireConsumer, WireEncode};
+ use iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE;
+ use iggy_binary_protocol::{Command, ReplyHeader, StartViewHeader, WireConsumer, WireEncode};
+ use journal::DurableAppend;
use message_bus::{BusMessage, SendError};
use server_common::MESSAGE_ALIGN;
use server_common::iobuf::Owned;
@@ -7441,8 +8445,490 @@
use std::cell::RefCell;
use std::rc::Rc;
+ #[cfg(target_os = "linux")]
+ use std::os::unix::fs::MetadataExt;
+
const TEST_CLUSTER: u128 = 1;
+ fn checksummed_segment_prepare(
+ op: u64,
+ parent: u128,
+ offset: u64,
+ payload: &[u8],
+ ) -> Message<PrepareHeader> {
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let body =
+ build_segment_record_with_payload(namespace, offset, Bytes::copy_from_slice(payload));
+ let total = size_of::<PrepareHeader>() + body.len();
+ let mut prepare = Message::<PrepareHeader>::new(total);
+ prepare.as_mut_slice()[size_of::<PrepareHeader>()..].copy_from_slice(&body);
+ prepare.transmute_header(|_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = TEST_CLUSTER;
+ header.group = namespace.inner();
+ header.op = op;
+ header.parent = parent;
+ header.client = 1;
+ header.request = op;
+ header.size = u32::try_from(total).unwrap();
+ header.checksum = header.identity_checksum();
+ })
+ }
+
+ #[compio::test]
+ async fn referenced_prepare_reads_reject_batch_and_payload_corruption_without_body_checksum() {
+ for corrupt_at in [
+ 0,
+ COMMAND_HEADER_SIZE,
+ COMMAND_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE,
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let wal = directory.path().join("prepares-0");
+ let prepare = checksummed_segment_prepare(1, 0, 0, b"payload");
+ assert_eq!(prepare.header().checksum_body, 0);
+ let group = prepare.header().group;
+ let mut journal = journal::PartitionPrepareJournal::open(&wal, group, 0)
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(
+ journal::partition_journal::SegmentPosition::default(),
+ 1024 * 1024,
+ )
+ .await
+ .unwrap();
+ journal.append(prepare.clone().into_frozen()).await.unwrap();
+ assert_eq!(
+ journal.prepares().await.unwrap()[0].as_slice(),
+ prepare.as_slice()
+ );
+ let path = directory.path().join("00000000000000000000.log");
+ let mut body = std::fs::read(&path).unwrap();
+ body[corrupt_at] ^= 1;
+ std::fs::write(&path, body).unwrap();
+ assert_eq!(
+ journal.prepares().await.unwrap_err().kind(),
+ std::io::ErrorKind::InvalidData
+ );
+ drop(journal);
+ assert!(
+ journal::PartitionPrepareJournal::open(&wal, group, 0)
+ .await
+ .is_err()
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn replicated_transfer_keeps_the_evicted_checkpoint_checksum_with_an_inflight_tail() {
+ let origin_directory = tempfile::tempdir().unwrap();
+ let receiver_directory = tempfile::tempdir().unwrap();
+ let (mut origin, _) = recording_partition_at(0, 3);
+ origin.set_partition_dir(origin_directory.path().to_string_lossy().into_owned());
+ let committed = checksummed_segment_prepare(1, 0, 0, b"committed");
+ let inflight = checksummed_segment_prepare(2, committed.header().checksum, 1, b"inflight");
+ for prepare in [&committed, &inflight] {
+ origin
+ .log
+ .journal()
+ .inner
+ .append(prepare.clone().into_frozen())
+ .await
+ .unwrap();
+ }
+ origin.consensus().sequencer().set_sequence(2);
+ origin
+ .consensus()
+ .set_last_prepare_checksum(inflight.header().checksum);
+ origin.consensus().advance_commit_max(1);
+ origin.consensus().advance_commit_min(1);
+ // Retention can remove all polled segments while the repair ring still
+ // serves the checkpoint prepare and the primary keeps accepting writes.
+ origin.offset_space.committed_seeded = true;
+ origin.offset.store(0, Ordering::Relaxed);
+ origin.log.journal().inner.evict_prefix(1).await;
+ assert!(origin.persistence.is_none());
+ assert!(origin.log.journal().inner.header_by_op(1).is_none());
+ assert!(origin.log.journal().inner.repair_entry(1).is_some());
+
+ let offer = origin.state_transfer_offer(&repair_config()).await.unwrap();
+ let offsets = crate::state_transfer::ConsumerOffsetsWire::decode(&offer.offsets.1).unwrap();
+ assert_eq!(offsets.prepare_checksum, Some(committed.header().checksum));
+ assert_eq!(offsets.checkpoint_prepare, committed.as_slice());
+ let (mut receiver, _) = recording_partition_at(1, 3);
+ receiver.set_partition_dir(receiver_directory.path().to_string_lossy().into_owned());
+ let consumers = receiver_directory.path().join("offsets/consumers");
+ let groups = receiver_directory.path().join("offsets/groups");
+ std::fs::create_dir_all(&consumers).unwrap();
+ std::fs::create_dir_all(&groups).unwrap();
+ receiver.consumer_offsets_path = Some(consumers.to_string_lossy().into_owned());
+ receiver.consumer_group_offsets_path = Some(groups.to_string_lossy().into_owned());
+ receiver
+ .install_state_transfer(
+ &repair_config(),
+ offer.commit_op,
+ Vec::new(),
+ &offer.offsets.1,
+ 0,
+ )
+ .await
+ .unwrap();
+ assert_eq!(receiver.consensus().commit_min(), 1);
+ assert_eq!(
+ receiver.consensus().last_prepare_checksum(),
+ committed.header().checksum
+ );
+ }
+
+ #[compio::test]
+ async fn transfer_establishes_wal_body_ownership_without_losing_the_active_index_writer() {
+ for durability in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ for materialized in [false, true] {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(1, 3);
+ let partition_dir = directory.path().to_string_lossy().into_owned();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.runtime_options.durability = durability;
+ partition.runtime_options.consumer_offset_durability =
+ iggy_common::Durability::Persisted;
+ for kind in ["consumers", "groups"] {
+ std::fs::create_dir_all(directory.path().join("offsets").join(kind)).unwrap();
+ }
+ partition.consumer_offsets_path =
+ Some(format!("{partition_dir}/offsets/consumers"));
+ partition.consumer_group_offsets_path =
+ Some(format!("{partition_dir}/offsets/groups"));
+ crate::state_transfer::mark_materialization_missing(&partition_dir, 0)
+ .await
+ .unwrap();
+ partition.open_persistence().await.unwrap();
+ assert!(
+ partition
+ .persistence
+ .as_ref()
+ .unwrap()
+ .segment_checkpoint()
+ .is_none()
+ );
+ let prepare = checksummed_segment_prepare(1, 0, 0, b"transferred");
+ let mut staged = Vec::new();
+ if materialized {
+ let body = prepare.as_slice()[size_of::<PrepareHeader>()..].to_vec();
+ let artifact = consensus::StateArtifact::for_bytes(
+ consensus::state_manifest::artifact_kind::SEGMENT_LOG,
+ 0,
+ &body,
+ );
+ staged.push(
+ partition
+ .spill_transfer_segment(&artifact, body)
+ .await
+ .unwrap(),
+ );
+ }
+ let offsets = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: Some(prepare.header().checksum),
+ checkpoint_prepare: prepare.as_slice().to_vec(),
+ purge_generation: 0,
+ next_offset: 1,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ dedup: Vec::new(),
+ };
+ partition
+ .install_state_transfer(&repair_config(), 1, staged, &offsets.encode(), 0)
+ .await
+ .unwrap();
+ assert!(
+ partition
+ .persistence
+ .as_ref()
+ .unwrap()
+ .segment_checkpoint()
+ .is_some()
+ );
+ assert!(
+ partition
+ .log
+ .storages()
+ .iter()
+ .all(|storage| storage.messages_writer.is_none())
+ );
+ assert!(partition.log.messages_writers().iter().all(Option::is_none));
+ assert!(partition.log.index_writers().last().unwrap().is_some());
+ assert_eq!(partition.consensus().commit_min(), 1);
+ }
+ }
+ }
+
+ #[compio::test]
+ async fn state_transfer_rejects_corrupted_checkpoint_payload_with_zero_body_checksum() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = test_partition();
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ let mut prepare = checksummed_segment_prepare(1, 0, 0, b"payload");
+ let checksum = prepare.header().checksum;
+ prepare.as_mut_slice()
+ [size_of::<PrepareHeader>() + COMMAND_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE] ^= 1;
+ let offsets = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: Some(checksum),
+ checkpoint_prepare: prepare.as_slice().to_vec(),
+ purge_generation: 0,
+ next_offset: 1,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ dedup: Vec::new(),
+ };
+ assert!(matches!(
+ partition
+ .install_state_transfer(&repair_config(), 1, Vec::new(), &offsets.encode(), 0)
+ .await,
+ Err(crate::state_transfer::PartitionInstallError::Offsets(
+ crate::state_transfer::ConsumerOffsetsWireError::InvalidPrepareChecksum
+ ))
+ ));
+ assert_eq!(partition.consensus().commit_min(), 0);
+ assert!(!directory.path().join("prepares-0").exists());
+ }
+
+ #[compio::test]
+ async fn pending_wal_prefix_keeps_pipeline_replies_and_does_not_partially_flush() {
+ for durability in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, replies) = recording_partition_at(0, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = durability;
+ partition.runtime_options.consumer_offset_durability =
+ iggy_common::Durability::Persisted;
+ let first = checksummed_segment_prepare(1, 0, 0, b"first");
+ let second = checksummed_segment_prepare(2, first.header().checksum, 1, b"second");
+ let segment_size =
+ IggyByteSize::from((first.as_slice().len() - size_of::<PrepareHeader>()) as u64);
+ partition.runtime_options.segment_size = Some(segment_size);
+ partition.log.active_segment_mut().max_size = segment_size;
+ partition.open_persistence().await.unwrap();
+ partition.log.retire_front().unwrap();
+ partition
+ .install_empty_segment(&repair_config(), 0)
+ .await
+ .unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+ persistence
+ .append(first.clone().into_frozen(), durability.is_persisted())
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ persistence
+ .append(second.clone().into_frozen(), durability.is_persisted())
+ .unwrap();
+ assert!(persistence.start());
+ let writer = Rc::clone(&persistence).run();
+ for prepare in [first, second] {
+ partition.consensus.with_pipeline_mut(|pipeline| {
+ pipeline.push(PipelineEntry::new(*prepare.header()));
+ });
+ partition
+ .append_repaired_send_messages(prepare)
+ .await
+ .unwrap();
+ }
+ partition.consensus.restore_commit_state(0, 2);
+ let config = repair_config();
+ {
+ let mut flush = Box::pin(partition.commit_messages_inner(&config, true, 2));
+ assert!(matches!(
+ futures::poll!(&mut flush),
+ std::task::Poll::Ready(Ok(false))
+ ));
+ }
+ assert_eq!(partition.log.journal().inner.resident_count(), 2);
+ assert_eq!(partition.log.active_segment().size.as_bytes_u64(), 0);
+ assert_eq!(partition.stats.messages_count_inconsistent(), 0);
+ partition.commit_journal(&config).await;
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus.commit_min(), 1);
+ assert_eq!(partition.consensus.pipeline_head_header().unwrap().op, 2);
+ assert_eq!(replies.borrow().len(), 1);
+ writer.await;
+ partition.commit_journal(&config).await;
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus.commit_min(), 2);
+ assert_eq!(partition.consensus.pipeline_len(), 0);
+ assert_eq!(partition.stats.messages_count_inconsistent(), 2);
+ assert_eq!(replies.borrow().len(), 2);
+ }
+ }
+
+ #[compio::test]
+ async fn mixed_durability_replies_before_body_writes_below_the_flush_threshold() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, replies) = recording_partition_at(0, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Replicated;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let mut config = repair_config();
+ config.messages_required_to_save = 2;
+ partition.log.retire_front().unwrap();
+ partition.install_empty_segment(&config, 0).await.unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+ let first = checksummed_segment_prepare(1, 0, 0, b"first");
+ let second = checksummed_segment_prepare(2, first.header().checksum, 1, b"second");
+ persistence
+ .append(first.clone().into_frozen(), false)
+ .unwrap();
+ assert!(persistence.start());
+ let writer = Rc::clone(&persistence).run();
+ for (index, prepare) in [first, second].into_iter().enumerate() {
+ let header = *prepare.header();
+ if index > 0 {
+ persistence
+ .append(prepare.clone().into_frozen(), false)
+ .unwrap();
+ }
+ partition.consensus.with_pipeline_mut(|pipeline| {
+ pipeline.push(PipelineEntry::new(header));
+ });
+ partition
+ .append_repaired_send_messages(prepare)
+ .await
+ .unwrap();
+ partition.consensus.advance_commit_max(header.op);
+ if index == 0 {
+ let write_lock = partition.write_lock.clone();
+ let _guard = write_lock.lock().await;
+ let mut commit = Box::pin(partition.commit_journal(&config));
+ assert!(
+ futures::poll!(&mut commit).is_ready(),
+ "a below-threshold reply must not acquire a later materialization requirement while waiting for the append lock"
+ );
+ } else {
+ partition.commit_journal(&config).await;
+ }
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus.commit_min(), 1);
+ assert_eq!(
+ replies.borrow().len(),
+ 1,
+ "only the below-threshold send can reply"
+ );
+ assert_eq!(partition.log.active_segment().size.as_bytes_u64(), 0);
+ assert_eq!(partition.log.journal().inner.resident_count(), index + 1);
+ assert!(!persistence.is_written(&header));
+ }
+ assert_eq!(partition.consensus.pipeline_head_header().unwrap().op, 2);
+ {
+ let mut flush = Box::pin(partition.commit_messages_inner(&config, true, 2));
+ assert!(matches!(
+ futures::poll!(&mut flush),
+ std::task::Poll::Ready(Ok(false))
+ ));
+ }
+ writer.await;
+ assert_eq!(persistence.durable_op(), 0);
+ partition.commit_journal(&config).await;
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus.commit_min(), 2);
+ assert_eq!(partition.consensus.pipeline_len(), 0);
+ assert_eq!(replies.borrow().len(), 2);
+ assert_eq!(partition.log.journal().inner.resident_count(), 0);
+ assert_eq!(partition.stats.messages_count_inconsistent(), 2);
+ }
+
+ #[compio::test]
+ async fn live_wal_rollback_preserves_replacement_indexes_and_pollable_bodies() {
+ let root = tempfile::tempdir().unwrap();
+ let mut config = repair_config();
+ config.path_layout.streams_root = root.path().to_string_lossy().into_owned();
+ let directory = std::path::PathBuf::from(config.get_partition_path(1, 1, 0));
+ std::fs::create_dir_all(&directory).unwrap();
+ let mut partition = partition_at_view(0, 0);
+ partition.set_partition_dir(directory.to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ let first = checksummed_segment_prepare(1, 0, 0, b"first");
+ let parent = first.header().checksum;
+ let segment_size =
+ IggyByteSize::from((first.as_slice().len() - size_of::<PrepareHeader>()) as u64);
+ partition.runtime_options.segment_size = Some(segment_size);
+ partition.log.active_segment_mut().max_size = segment_size;
+ partition.open_persistence().await.unwrap();
+ partition.log.retire_front().unwrap();
+ partition
+ .install_empty_segment(&repair_config(), 0)
+ .await
+ .unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+ persistence
+ .append(first.clone().into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ partition
+ .append_repaired_send_messages(first)
+ .await
+ .unwrap();
+ partition.consensus.restore_commit_state(0, 1);
+ partition.commit_journal(&repair_config()).await;
+ let old = checksummed_segment_prepare(2, parent, 1, b"discarded");
+ persistence.append(old.clone().into_frozen(), true).unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ partition.append_repaired_send_messages(old).await.unwrap();
+ partition.truncate_uncommitted_from(2).await.unwrap();
+ let replacement = checksummed_segment_prepare(2, parent, 1, b"replacement");
+ let expected = replacement.as_slice()[size_of::<PrepareHeader>()..].to_vec();
+ persistence
+ .append(replacement.clone().into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ partition
+ .append_repaired_send_messages(replacement)
+ .await
+ .unwrap();
+ partition.consensus.advance_commit_max(2);
+ partition.commit_journal(&repair_config()).await;
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus.commit_min(), 2);
+ assert_eq!(
+ std::fs::read(directory.join("00000000000000000001.log")).unwrap(),
+ expected
+ );
+ assert_eq!(
+ std::fs::metadata(directory.join("00000000000000000001.index"))
+ .unwrap()
+ .len(),
+ IGGY_INDEX_SIZE as u64
+ );
+ assert_eq!(partition.log.journal().inner.resident_count(), 0);
+ let args = PollingArgs::new(iggy_common::PollingStrategy::offset(1), 1, false);
+ let (fragments, _, _) = partition
+ .build_poll_plan(PollingConsumer::Consumer(1, 0), &args, true)
+ .execute()
+ .await
+ .unwrap();
+ let polled: Vec<_> = fragments
+ .iter()
+ .flat_map(|fragment| fragment.as_slice().iter().copied())
+ .collect();
+ let batch = decode_batch_slice(&polled).unwrap();
+ assert_eq!(batch.header.base_offset, 1);
+ assert_eq!(batch.message_count(), 1);
+ assert_eq!(batch.iter().next().unwrap().payload, b"replacement");
+ persistence.request_checkpoint();
+ partition.checkpoint_persistence(&config).await;
+ persistence.drain_with_timeout().await.unwrap();
+ assert!(persistence.failure().is_none());
+ assert_eq!(persistence.checkpoint_op(), 2);
+ }
+
pub(super) fn test_partition() -> IggyPartition<IggyMessageBus> {
let namespace = IggyNamespace::new(1, 1, 0);
let consensus = VsrConsensus::new(
@@ -7458,7 +8944,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
)
}
@@ -7478,10 +8963,622 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
)
}
+ #[compio::test]
+ async fn missing_materialization_does_not_restore_wal_checkpoint_as_applied() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(1, 1);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ let mut journal = journal::PartitionPrepareJournal::open(
+ &directory.path().join("prepares-0"),
+ partition.consensus().group(),
+ 0,
+ )
+ .await
+ .unwrap();
+ journal.reset(7, Some(1234)).await.unwrap();
+ drop(journal);
+ crate::state_transfer::mark_materialization_missing(directory.path().to_str().unwrap(), 0)
+ .await
+ .unwrap();
+ partition.open_persistence().await.unwrap();
+ assert!(partition.requires_state_transfer());
+ assert_eq!(partition.consensus().commit_min(), 0);
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 0);
+ assert!(partition.consensus().is_transferring());
+ assert_eq!(partition.persistence.as_ref().unwrap().checkpoint_op(), 7);
+ partition.commit_journal(&repair_config()).await;
+ assert_eq!(partition.consensus().commit_min(), 0);
+ }
+
+ #[cfg(target_os = "linux")]
+ #[compio::test]
+ async fn given_persisted_preallocation_when_rotating_should_reserve_without_extending() {
+ const SEGMENT_BYTES: u64 = 1024 * 1024;
+ const BLOCK_BYTES: u64 = 512;
+
+ for preallocate in [false, true] {
+ let directory = tempfile::tempdir().unwrap();
+ let probe = tempfile::tempfile_in(directory.path()).unwrap();
+ let preallocation_supported = match nix::fcntl::fallocate(
+ &probe,
+ nix::fcntl::FallocateFlags::FALLOC_FL_KEEP_SIZE,
+ 0,
+ i64::try_from(SEGMENT_BYTES).unwrap(),
+ ) {
+ Ok(()) => true,
+ Err(nix::errno::Errno::EOPNOTSUPP | nix::errno::Errno::ENOSYS) => false,
+ Err(error) => panic!("preallocation probe failed: {error}"),
+ };
+ let mut partition = partition_at_view(0, 0);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.runtime_options.preallocate_segments = Some(preallocate);
+ partition.runtime_options.segment_size = Some(IggyByteSize::from(SEGMENT_BYTES));
+ partition.open_persistence().await.unwrap();
+ partition.log.retire_front().unwrap();
+ partition
+ .install_empty_segment(&repair_config(), 0)
+ .await
+ .unwrap();
+ let empty =
+ std::fs::metadata(directory.path().join("00000000000000000000.log")).unwrap();
+ assert_eq!(empty.len(), 0);
+ if preallocation_supported {
+ assert_eq!(
+ empty.blocks() * BLOCK_BYTES >= SEGMENT_BYTES,
+ preallocate,
+ "empty active segment allocation must follow preallocate_segments={preallocate}"
+ );
+ }
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+ let namespace = partition.namespace();
+ let bodies = [
+ build_segment_record_with_payload(
+ namespace,
+ 0,
+ Bytes::from(vec![1; usize::try_from(SEGMENT_BYTES).unwrap()]),
+ ),
+ build_segment_record(namespace, 1),
+ ];
+ let mut parent = 0;
+ for (offset, body) in bodies.iter().enumerate() {
+ let total = size_of::<PrepareHeader>() + body.len();
+ let mut prepare = Message::<PrepareHeader>::new(total);
+ prepare.as_mut_slice()[size_of::<PrepareHeader>()..].copy_from_slice(body);
+ let prepare = prepare.transmute_header(|_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = TEST_CLUSTER;
+ header.group = namespace.inner();
+ header.op = u64::try_from(offset).unwrap() + 1;
+ header.parent = parent;
+ header.size = u32::try_from(total).unwrap();
+ header.checksum_body = u128::from(iggy_common::calculate_checksum(body));
+ header.checksum = header.identity_checksum();
+ });
+ parent = prepare.header().checksum;
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ }
+ partition.start_persistence();
+ persistence.drain_with_timeout().await.unwrap();
+
+ let rotated = directory.path().join("00000000000000000001.log");
+ let metadata = std::fs::metadata(&rotated).unwrap();
+ assert_eq!(std::fs::read(&rotated).unwrap(), bodies[1]);
+ assert_eq!(metadata.len(), bodies[1].len() as u64);
+ if preallocation_supported {
+ assert_eq!(
+ metadata.blocks() * BLOCK_BYTES >= SEGMENT_BYTES,
+ preallocate,
+ "rotated segment allocation must follow preallocate_segments={preallocate}"
+ );
+ }
+ assert_eq!(partition.log.active_segment().size.as_bytes_u64(), 0);
+ }
+ }
+
+ #[compio::test]
+ async fn retransmission_catches_up_sequencer_after_wal_admission_backpressure() {
+ let mut partition = partition_at_view(1, 1);
+ let prepare = Message::<PrepareHeader>::new(size_of::<PrepareHeader>()).transmute_header(
+ |_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::StoreConsumerOffset;
+ header.view = 1;
+ header.replica = 1;
+ header.group = partition.consensus().group();
+ header.cluster = TEST_CLUSTER;
+ header.op = 1;
+ header.timestamp = 1;
+ header.size = u32::try_from(size_of::<PrepareHeader>()).unwrap();
+ header.checksum = header.identity_checksum();
+ },
+ );
+ let checksum = prepare.header().checksum;
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.clone().into_frozen())
+ .await
+ .unwrap();
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 0);
+ partition.on_replicate(prepare).await;
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 1);
+ assert_eq!(partition.consensus().last_prepare_checksum(), checksum);
+ }
+
+ #[compio::test]
+ async fn uncertified_log_view_requires_transfer_before_voting() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(2, 2);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ let mut journal = journal::PartitionPrepareJournal::open(
+ &directory.path().join("prepares-0"),
+ partition.consensus().group(),
+ 0,
+ )
+ .await
+ .unwrap();
+ journal.reset(7, Some(1234)).await.unwrap();
+ journal.certify_log_view(1, 7, 1234).await.unwrap();
+ drop(journal);
+ partition.open_persistence().await.unwrap();
+ assert!(partition.requires_state_transfer());
+ assert!(partition.consensus().is_transferring());
+ assert_eq!(partition.consensus().commit_min(), 0);
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 0);
+ }
+
+ #[compio::test]
+ async fn promotion_waits_for_the_matching_local_prepare_to_be_durable() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(1, 1);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let prepare = Message::<PrepareHeader>::new(size_of::<PrepareHeader>()).transmute_header(
+ |_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::StoreConsumerOffset;
+ header.view = 1;
+ header.replica = 1;
+ header.group = partition.consensus().group();
+ header.cluster = TEST_CLUSTER;
+ header.op = 1;
+ header.timestamp = 1;
+ header.size = u32::try_from(size_of::<PrepareHeader>()).unwrap();
+ header.checksum = header.identity_checksum();
+ },
+ );
+ let header = *prepare.header();
+ let persistence = partition.persistence.as_ref().unwrap();
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ assert!(!partition.register_rebuilt_ack(&header));
+ assert!(partition.pending_persisted_acks.borrow().contains_key(&1));
+ partition.start_persistence();
+ persistence.drain_with_timeout().await.unwrap();
+ assert!(partition.register_rebuilt_ack(&header));
+ }
+
+ #[compio::test]
+ async fn live_retransmits_fill_an_announced_gap_only_after_the_matching_prefix() {
+ for durability in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(1, 3);
+ let sent = partition.consensus().message_bus().sent_to_replicas.clone();
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = durability;
+ partition.open_persistence().await.unwrap();
+ let first = checksummed_segment_prepare(1, 0, 0, b"first");
+ let second = checksummed_segment_prepare(2, first.header().checksum, 1, b"second");
+ let third = checksummed_segment_prepare(3, second.header().checksum, 2, b"third");
+ let start = Message::<StartViewHeader>::new(size_of::<StartViewHeader>())
+ .transmute_header(|_, header: &mut StartViewHeader| {
+ header.command = Command::StartView;
+ header.cluster = TEST_CLUSTER;
+ header.group = partition.namespace().inner();
+ header.op = third.header().op;
+ header.size = u32::try_from(size_of::<StartViewHeader>()).unwrap();
+ });
+ partition
+ .consensus()
+ .handle_start_view(PlaneKind::Partitions, start.header(), &[]);
+ assert!(!partition.consensus().view_log_is_pending());
+ partition.repair = Some(armed_fetch_session(0, 3, 0, None));
+ partition.apply_repaired_prepare(first.clone()).await;
+ assert!(
+ !partition.log.journal().inner.holds_op(1),
+ "unattributed repair still needs a canonical header above commit"
+ );
+ partition.repair = None;
+
+ partition.on_replicate(third.clone()).await;
+ let fork = first
+ .clone()
+ .transmute_header(|original, header: &mut PrepareHeader| {
+ *header = original;
+ header.parent = u128::MAX;
+ header.checksum = header.identity_checksum();
+ });
+ partition.on_replicate(fork).await;
+ assert!(!partition.log.journal().inner.holds_op(1));
+ assert!(!partition.log.journal().inner.holds_op(3));
+ assert!(sent.borrow().is_empty());
+
+ partition.on_replicate(first).await;
+ partition.on_replicate(third.clone()).await;
+ assert!(
+ !partition.log.journal().inner.holds_op(3),
+ "op 2 is still missing"
+ );
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 3);
+ partition.on_replicate(second.clone()).await;
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 3);
+ partition.on_replicate(third.clone()).await;
+ if let Some(persistence) = &partition.persistence {
+ persistence.drain_with_timeout().await.unwrap();
+ }
+ partition.drive_persistence().await;
+ assert_eq!(
+ partition.consensus().last_prepare_checksum(),
+ third.header().checksum
+ );
+ assert!(!partition.consensus().view_log_is_pending());
+ let acked: Vec<_> = sent
+ .borrow()
+ .iter()
+ .filter_map(|(_, frame)| {
+ bytemuck::checked::try_from_bytes::<PrepareOkHeader>(frame.as_slice())
+ .ok()
+ .filter(|header| header.command == Command::PrepareOk)
+ .map(|header| header.op)
+ })
+ .collect();
+ assert_eq!(
+ acked,
+ vec![1, 2, 3],
+ "every held body must eventually be acknowledged under {durability:?}"
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn purged_announced_prepare_cannot_repopulate_the_reset_offset_space() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(1, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ let before_purge = checksummed_segment_prepare(1, 0, 0, b"before-purge");
+ let after_purge =
+ checksummed_segment_prepare(2, before_purge.header().checksum, 0, b"after-purge");
+ partition.consensus().sequencer().set_sequence(1);
+ partition.purge(&repair_config(), 1).await.unwrap();
+ partition.on_replicate(before_purge).await;
+ assert!(!partition.log.journal().inner.holds_op(1));
+ assert_eq!(partition.mint_frontier(), 0);
+ partition.on_replicate(after_purge).await;
+ assert!(partition.log.journal().inner.holds_op(2));
+ assert_eq!(partition.mint_frontier(), 1);
+ }
+
+ async fn partition_with_pending_durable_ack() -> (
+ tempfile::TempDir,
+ IggyPartition<RecordingBus>,
+ PrepareHeader,
+ ) {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(0, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let prepare = checksummed_segment_prepare(1, 0, 0, b"parked");
+ let header = *prepare.header();
+ partition.consensus().sequencer().set_sequence(header.op);
+ partition
+ .consensus()
+ .set_last_prepare_checksum(header.checksum);
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.clone().into_frozen())
+ .await
+ .unwrap();
+ let persistence = partition.persistence.as_ref().unwrap();
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ assert!(!partition.register_rebuilt_ack(&header));
+ partition.start_persistence();
+ persistence.drain_with_timeout().await.unwrap();
+
+ (directory, partition, header)
+ }
+
+ #[compio::test]
+ async fn pending_wal_acks_wait_for_the_superblock_prepass() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(3, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let prepare = checksummed_segment_prepare(1, 0, 0, b"pending").transmute_header(
+ |mut original, header: &mut PrepareHeader| {
+ original.view = 3;
+ original.checksum = original.identity_checksum();
+ *header = original;
+ },
+ );
+ let header = *prepare.header();
+ partition.consensus().sequencer().set_sequence(header.op);
+ partition
+ .consensus()
+ .set_last_prepare_checksum(header.checksum);
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.clone().into_frozen())
+ .await
+ .unwrap();
+ let persistence = partition.persistence.as_ref().unwrap();
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ assert!(!partition.register_rebuilt_ack(&header));
+ persistence.certify_log_view(3, header.op, header.checksum);
+ partition.start_persistence();
+ persistence.drain_with_timeout().await.unwrap();
+ let store = Rc::new(RecordingSuperblock::default());
+ partition.set_superblock(store.clone(), None);
+ assert!(partition.consensus().needs_superblock_persist());
+
+ partition.drive_persistence().await;
+
+ assert_eq!(
+ store.attempts.get(),
+ 0,
+ "the serial ACK drain must not issue a superblock write"
+ );
+ assert!(
+ partition
+ .pending_persisted_acks
+ .borrow()
+ .contains_key(&header.op)
+ );
+ assert!(partition.persist_superblock_if_needed().await);
+ partition.drive_persistence().await;
+ assert_eq!(store.attempts.get(), 1);
+ assert!(partition.pending_persisted_acks.borrow().is_empty());
+ }
+
+ #[compio::test]
+ async fn persisted_ack_survives_recovery_transfer_and_a_rewound_head() {
+ let (_directory, mut partition, header) = partition_with_pending_durable_ack().await;
+ partition.consensus().begin_view_probe();
+ partition.drive_persistence().await;
+ assert_eq!(partition.pending_persisted_acks.borrow().len(), 1);
+
+ partition.consensus().init();
+ partition.consensus().begin_state_transfer_await();
+ partition.drive_persistence().await;
+ assert_eq!(partition.pending_persisted_acks.borrow().len(), 1);
+
+ partition
+ .consensus()
+ .set_state_transfer_stage(consensus::StateTransferStage::Idle);
+ partition
+ .consensus()
+ .sequencer()
+ .set_sequence(header.op - 1);
+ partition.drive_persistence().await;
+ assert_eq!(partition.pending_persisted_acks.borrow().len(), 1);
+ let mut acknowledgments = Vec::new();
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(acknowledgments.is_empty());
+
+ partition.consensus().sequencer().set_sequence(header.op);
+ partition.drive_persistence().await;
+ partition.drive_persistence().await;
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(partition.pending_persisted_acks.borrow().is_empty());
+ assert_eq!(acknowledgments.len(), 1);
+ let ack = bytemuck::checked::from_bytes::<PrepareOkHeader>(acknowledgments[0].as_slice());
+ assert_eq!(ack.op, header.op);
+ assert_eq!(ack.prepare_checksum, header.checksum);
+ }
+
+ #[compio::test]
+ async fn persisted_ack_remains_fenced_after_a_local_commit_failure() {
+ let (_directory, mut partition, header) = partition_with_pending_durable_ack().await;
+ partition.fatal = Some(FatalCommit {
+ namespace_raw: partition.namespace().inner(),
+ op: header.op,
+ operation: Operation::StoreConsumerOffset,
+ });
+ partition.persistence.as_ref().unwrap().request_checkpoint();
+ partition
+ .consensus()
+ .restore_commit_state(header.op, header.op);
+ partition.checkpoint_persistence(&repair_config()).await;
+ partition.drive_persistence().await;
+ partition.acknowledge_prepare(header.op).await;
+ let mut acknowledgments = Vec::new();
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(
+ acknowledgments.is_empty(),
+ "a fenced partition must never acknowledge"
+ );
+ assert_eq!(partition.pending_persisted_acks.borrow().len(), 1);
+ assert_eq!(
+ partition.fatal().unwrap().operation,
+ Operation::StoreConsumerOffset
+ );
+ }
+
+ #[compio::test]
+ async fn deferred_purge_preserves_a_durable_primary_self_ack_until_it_can_be_sent() {
+ let (_directory, mut partition, header) = partition_with_pending_durable_ack().await;
+
+ partition.purge_deferred = true;
+ let mut acknowledgments = Vec::new();
+ for _ in 0..2 {
+ partition.drive_persistence().await;
+ assert!(
+ partition
+ .pending_persisted_acks
+ .borrow()
+ .contains_key(&header.op)
+ );
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(acknowledgments.is_empty());
+ }
+ partition.purge_deferred = false;
+ partition.drive_persistence().await;
+ partition.drive_persistence().await;
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(partition.pending_persisted_acks.borrow().is_empty());
+ assert_eq!(
+ acknowledgments.len(),
+ 1,
+ "the primary self-ack must be delivered once"
+ );
+ let ack = bytemuck::checked::from_bytes::<PrepareOkHeader>(acknowledgments[0].as_slice());
+ assert_eq!(ack.op, header.op);
+ assert_eq!(ack.prepare_checksum, header.checksum);
+ }
+
+ #[compio::test]
+ async fn consumer_offset_open_failure_does_not_poison_the_wal_and_can_be_retried() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(0, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let path = directory.path().join("consumer-offset");
+ std::fs::create_dir(&path).unwrap();
+ assert!(matches!(
+ partition
+ .write_consumer_offset(path.to_str().unwrap(), 7, false)
+ .await,
+ Err(IggyError::CannotOpenConsumerOffsetsFile(_))
+ ));
+ partition.drive_persistence().await;
+ assert!(partition.persistence.as_ref().unwrap().failure().is_none());
+ assert!(partition.fatal.is_none());
+
+ std::fs::remove_dir(&path).unwrap();
+ partition
+ .write_consumer_offset(path.to_str().unwrap(), 7, false)
+ .await
+ .unwrap();
+ assert_eq!(
+ std::fs::read(&path).unwrap(),
+ crate::offset_storage::encode_offset_record(7)
+ );
+ assert!(
+ partition
+ .persistence
+ .as_ref()
+ .unwrap()
+ .take_offset_file(path.to_str().unwrap())
+ .is_some()
+ );
+ }
+
+ #[cfg(target_os = "linux")]
+ #[compio::test]
+ async fn consumer_offset_write_failure_keeps_its_error_kind_and_original_writer() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(0, 3);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ assert!(matches!(
+ partition.write_consumer_offset(DEV_FULL, 7, false).await,
+ Err(IggyError::CannotWriteToFile)
+ ));
+ let persistence = partition.persistence.as_ref().unwrap();
+ assert_eq!(
+ persistence.failure().unwrap().kind(),
+ std::io::ErrorKind::StorageFull
+ );
+ assert!(persistence.take_offset_file(DEV_FULL).is_some());
+ partition.drive_persistence().await;
+ assert!(
+ partition.fatal.is_some(),
+ "a failed write must remain fenced"
+ );
+ assert_eq!(
+ partition.fatal.as_ref().unwrap().operation,
+ Operation::StoreConsumerOffset
+ );
+ }
+
+ #[compio::test]
+ async fn checkpoint_paths_include_offset_parent_directories() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(1, 1);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.consumer_offsets_path = Some(
+ directory
+ .path()
+ .join("offsets/consumers")
+ .to_string_lossy()
+ .into_owned(),
+ );
+ partition.consumer_group_offsets_path = Some(
+ directory
+ .path()
+ .join("offsets/groups")
+ .to_string_lossy()
+ .into_owned(),
+ );
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let (_, directories) = partition.persistence_checkpoint_files(&repair_config());
+ assert!(directories.contains(&directory.path().join("offsets")));
+ assert!(directories.contains(&directory.path().join("offsets/consumers")));
+ assert!(directories.contains(&directory.path().join("offsets/groups")));
+ assert!(directories.contains(&directory.path().to_path_buf()));
+ }
+
+ #[compio::test]
+ async fn checkpoint_only_recovery_restores_the_prepare_chain_anchor() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(1, 1);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ let mut journal = journal::PartitionPrepareJournal::open(
+ &directory.path().join("prepares-0"),
+ partition.consensus().group(),
+ 0,
+ )
+ .await
+ .unwrap();
+ journal.reset(7, Some(1234)).await.unwrap();
+ journal.certify_log_view(1, 7, 1234).await.unwrap();
+ drop(journal);
+ partition.open_persistence().await.unwrap();
+ assert_eq!(partition.consensus().sequencer().current_sequence(), 7);
+ assert_eq!(partition.consensus().commit_min(), 7);
+ assert_eq!(partition.consensus().last_prepare_checksum(), 1234);
+ }
+
/// Partition whose consensus already advanced to `(view, log_view)` with
/// nothing marked durable, as after a view change and before the persist
/// gate runs.
@@ -7505,7 +9602,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
)
}
@@ -8180,6 +10276,8 @@
);
let offer = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 1_030,
consumers: Vec::new(),
@@ -8484,7 +10582,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
);
let store = Rc::new(RecordingSuperblock::default());
store.fail_writes.set(true);
@@ -8525,6 +10622,10 @@
"the retried ack must go out once the view persisted"
);
assert!(!partition.consensus().needs_superblock_persist());
+ assert!(
+ partition.pending_persisted_acks.borrow().is_empty(),
+ "a partition without a WAL has no persistence driver to drain queued acks"
+ );
}
#[compio::test]
@@ -8618,7 +10719,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
);
(partition, sent_to_clients)
}
@@ -8736,6 +10836,8 @@
partition.consumer_offsets_path = Some(consumers.to_string_lossy().into_owned());
partition.consumer_group_offsets_path = Some(groups.to_string_lossy().into_owned());
let wire = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 10,
consumers: vec![(7, 1), (8, 2)],
@@ -8794,12 +10896,356 @@
}
#[compio::test]
+ async fn durability_combinations_share_body_ownership_and_keep_materialization_thresholds() {
+ const SEGMENT_BYTES: u64 = 1024;
+ for replicas in [1, 3] {
+ for durability in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ for offset_durability in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ for byte_threshold in [false, true] {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(0, replicas);
+ partition
+ .set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = durability;
+ partition.runtime_options.consumer_offset_durability = offset_durability;
+ partition.runtime_options.preallocate_segments = Some(false);
+ partition.runtime_options.segment_size =
+ Some(IggyByteSize::from(SEGMENT_BYTES));
+ partition.log.segments_mut()[0].max_size =
+ IggyByteSize::from(SEGMENT_BYTES);
+ partition.open_persistence().await.unwrap();
+ let persistence = partition.persistence.clone();
+ let owned = replicas > 1
+ && (durability.is_persisted() || offset_durability.is_persisted());
+ assert_eq!(persistence.is_some(), owned);
+ let mut config = repair_config();
+ config.messages_required_to_save =
+ if byte_threshold { u32::MAX } else { 2 };
+ partition.log.retire_front().unwrap();
+ partition.install_empty_segment(&config, 0).await.unwrap();
+ assert_eq!(partition.log.messages_writers()[0].is_none(), owned);
+ let mut expected = Vec::new();
+ let mut parent = 0;
+ for op in 1..=3 {
+ let namespace = partition.namespace();
+ let request = checksumless_send_request(namespace, 1);
+ let prepare =
+ request.transmute_header(|old, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = TEST_CLUSTER;
+ header.group = namespace.inner();
+ header.op = op;
+ header.parent = parent;
+ header.timestamp = op;
+ header.size = old.size;
+ });
+ let prepare = partition
+ .stamp_and_append_messages(prepare)
+ .await
+ .unwrap()
+ .prepare;
+ let header = *bytemuck::checked::from_bytes::<PrepareHeader>(
+ &prepare.as_slice()[..size_of::<PrepareHeader>()],
+ );
+ parent = header.checksum;
+ expected.extend_from_slice(
+ &prepare.as_slice()[size_of::<PrepareHeader>()..],
+ );
+ partition.consensus().sequencer().set_sequence(op);
+ assert!(
+ partition
+ .submit_prepare_persistence(prepare, Operation::SendMessages)
+ );
+ if replicas > 1 {
+ assert_eq!(
+ partition.send_prepare_ok(&header).await,
+ !durability.is_persisted()
+ );
+ }
+ if let Some(persistence) = &persistence {
+ persistence.drain_with_timeout().await.unwrap();
+ assert!(partition.send_prepare_ok(&header).await);
+ }
+ partition.consensus().advance_commit_max(op);
+ if op == 1 && byte_threshold {
+ config.size_of_messages_required_to_save = IggyByteSize::from(
+ 2 * partition.log.journal().info.size.as_bytes_u64(),
+ );
+ }
+ partition.commit_messages(&config, op).await.unwrap();
+ if op == 1 {
+ assert_eq!(
+ partition
+ .log
+ .segments()
+ .iter()
+ .any(|segment| segment.size.as_bytes_u64() > 0),
+ replicas == 1 && durability.is_persisted(),
+ "replicas={replicas} durability={durability:?} offsets={offset_durability:?} byte_threshold={byte_threshold}",
+ );
+ } else if op == 2 {
+ assert_eq!(partition.log.journal().info.messages_count, 0);
+ }
+ }
+ partition.flush_committed_messages(&config).await.unwrap();
+ let mut actual = Vec::new();
+ for segment in partition.log.segments() {
+ let public = directory
+ .path()
+ .join(format!("{:020}.log", segment.start_offset));
+ actual.extend(std::fs::read(&public).unwrap());
+ let metadata = std::fs::metadata(public).unwrap();
+ assert_eq!(metadata.len(), segment.size.as_bytes_u64());
+ #[cfg(target_os = "linux")]
+ if owned && metadata.len() > 0 {
+ let wal = directory
+ .path()
+ .join(format!("prepares-{}", partition.created_revision));
+ assert!(
+ std::fs::read_dir(wal).unwrap().any(|entry| entry
+ .unwrap()
+ .metadata()
+ .unwrap()
+ .ino()
+ == metadata.ino())
+ );
+ }
+ }
+ assert_eq!(
+ actual, expected,
+ "replicas={replicas} durability={durability:?} offsets={offset_durability:?} byte_threshold={byte_threshold}"
+ );
+ assert_eq!(partition.log.journal().info.messages_count, 0);
+ if let Some(persistence) = persistence {
+ assert!(persistence.segment_checkpoint().is_some());
+ assert_eq!(
+ persistence.durable_op(),
+ if durability.is_persisted() { 3 } else { 0 }
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #[compio::test]
+ async fn wal_backpressure_preserves_replay_outcomes() {
+ for expected_status in [
+ 0,
+ IggyError::TransientNotCommitted.as_code(),
+ IggyError::TransientNotAccepted.as_code(),
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, replies) = recording_partition_at(0, 3);
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.open_persistence().await.unwrap();
+ let mut request = checksumless_send_request(partition.namespace(), 1);
+ request.as_mut_slice()[size_of::<RoutedRequestHeader>()..]
+ .copy_from_slice(&build_segment_record(partition.namespace(), 0));
+ let header = *request.header();
+ if expected_status == 0 {
+ partition
+ .dedup
+ .commit_request(header.client, header.user_id, header.request, 1);
+ } else if expected_status == IggyError::TransientNotCommitted.as_code() {
+ let prepare =
+ request
+ .clone()
+ .transmute_header(|request, prepare: &mut PrepareHeader| {
+ prepare.command = Command::Prepare;
+ prepare.operation = request.operation;
+ prepare.client = request.client;
+ prepare.user_id = request.user_id;
+ prepare.request = request.request;
+ prepare.op = 1;
+ prepare.size = request.size;
+ });
+ partition
+ .consensus()
+ .pipeline_message(PlaneKind::Partitions, &prepare);
+ }
+ let pipeline_len = partition.consensus().pipeline_len();
+ partition
+ .persistence
+ .as_ref()
+ .unwrap()
+ .exhaust_capacity_for_test();
+
+ partition.on_request(request, None).await;
+
+ let replies = replies.borrow();
+ assert_eq!(
+ replies.len(),
+ 1,
+ "every retry must receive its known outcome"
+ );
+ let reply = bytemuck::checked::from_bytes::<ReplyHeader>(
+ &replies[0].1.as_slice()[..size_of::<ReplyHeader>()],
+ );
+ assert_eq!(reply.status, expected_status);
+ assert_eq!(partition.consensus().pipeline_len(), pipeline_len);
+ assert_eq!(partition.persistence.as_ref().unwrap().head(), 0);
+ }
+ }
+
+ #[compio::test]
+ async fn wal_backpressure_on_an_intermediate_replica_does_not_stop_forwarding() {
+ let (mut primary, _) = recording_partition_at(0, 3);
+ let namespace = primary.namespace();
+ let request = checksumless_send_request(namespace, 1);
+ let prepare = request.transmute_header(|old, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = TEST_CLUSTER;
+ header.group = namespace.inner();
+ header.op = 1;
+ header.timestamp = 1;
+ header.size = old.size;
+ });
+ let forwarded = primary
+ .stamp_and_append_messages(prepare)
+ .await
+ .unwrap()
+ .prepare;
+ let message = Message::<PrepareHeader>::try_from(
+ server_common::iobuf::Owned::copy_from_slice(forwarded.as_slice()),
+ )
+ .unwrap();
+ let directory = tempfile::tempdir().unwrap();
+ let (mut backup, _) = recording_partition_at(1, 3);
+ backup.runtime_options.durability = iggy_common::Durability::Persisted;
+ backup.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ backup.open_persistence().await.unwrap();
+ backup
+ .persistence
+ .as_ref()
+ .unwrap()
+ .exhaust_capacity_for_test();
+ backup.on_replicate(message).await;
+ {
+ let sent = backup.consensus().message_bus().sent_to_replicas.borrow();
+ let (_, forwarded) = sent
+ .iter()
+ .find(|(target, _)| *target == 2)
+ .expect("the downstream replica receives the prepare");
+ let header = bytemuck::checked::from_bytes::<PrepareHeader>(
+ &forwarded.as_slice()[..size_of::<PrepareHeader>()],
+ );
+ assert_eq!(header.command, Command::Prepare);
+ assert_eq!(backup.consensus().sequencer().current_sequence(), 1);
+ assert_eq!(backup.persistence.as_ref().unwrap().head(), 0);
+ assert!(!sent.iter().any(|(target, _)| *target == 0));
+ }
+
+ backup
+ .persistence
+ .as_ref()
+ .unwrap()
+ .release_capacity_for_test();
+ let request = checksumless_send_request(namespace, 2);
+ let prepare = request.transmute_header(|old, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = TEST_CLUSTER;
+ header.group = namespace.inner();
+ header.op = 2;
+ header.timestamp = 2;
+ header.size = old.size;
+ });
+ let forwarded = primary
+ .stamp_and_append_messages(prepare)
+ .await
+ .unwrap()
+ .prepare;
+ let message =
+ Message::<PrepareHeader>::try_from(Owned::copy_from_slice(forwarded.as_slice()))
+ .unwrap();
+ backup.on_replicate(message).await;
+ let persistence = backup.persistence.as_ref().unwrap();
+ assert_eq!(persistence.head(), 2);
+ assert_eq!(backup.consensus().sequencer().current_sequence(), 2);
+ assert!(persistence.failure().is_none());
+ persistence.drain_with_timeout().await.unwrap();
+ assert!(persistence.is_durable_through(2));
+ }
+
+ #[compio::test]
+ async fn persisted_singleton_with_failed_segment_directory_sync_withholds_reply() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, replies) = recording_partition_at(0, 1);
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.set_partition_dir(
+ directory
+ .path()
+ .join("missing")
+ .to_string_lossy()
+ .into_owned(),
+ );
+ partition.consensus().advance_commit_max(1);
+ let header = PrepareHeader {
+ command: Command::Prepare,
+ operation: Operation::SendMessages,
+ op: 1,
+ group: partition.consensus().group(),
+ client: 1,
+ request: 1,
+ ..PrepareHeader::default()
+ };
+ partition
+ .handle_committed_entries(vec![PipelineEntry::new(header)], &repair_config(), true)
+ .await;
+ assert!(partition.fatal().is_some());
+ assert_eq!(partition.consensus().commit_min(), 0);
+ assert!(replies.borrow().is_empty());
+ }
+
+ #[compio::test]
+ async fn persisted_singleton_skips_directory_sync_after_names_are_published() {
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _) = recording_partition_at(0, 1);
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ for op in 1..=2 {
+ partition.consensus().advance_commit_max(op);
+ let header = PrepareHeader {
+ command: Command::Prepare,
+ operation: Operation::SendMessages,
+ op,
+ group: partition.consensus().group(),
+ client: 1,
+ request: op,
+ ..PrepareHeader::default()
+ };
+ partition
+ .handle_committed_entries(vec![PipelineEntry::new(header)], &repair_config(), true)
+ .await;
+ assert!(partition.fatal().is_none());
+ assert_eq!(partition.consensus().commit_min(), op);
+ assert!(!partition.segment_names_dirty.get());
+ if op == 1 {
+ std::fs::remove_dir(directory.path()).unwrap();
+ }
+ }
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ assert!(partition.segment_names_dirty.get());
+ }
+
+ #[compio::test]
async fn given_committed_deletes_when_drained_together_should_sync_directory_once_before_replies()
{
let dir = tempfile::tempdir().unwrap();
let (mut partition, sent) = recording_partition_at(0, 3);
partition.consumer_offsets_path = Some(dir.path().to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
let mut drained = Vec::new();
for id in 1..=2 {
partition
@@ -8895,7 +11341,7 @@
let dir = tempfile::tempdir().unwrap();
let (mut partition, _) = recording_partition();
partition.consumer_offsets_path = Some(dir.path().to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
let path = dir.path().join("7");
persist_offset(path.to_str().unwrap(), 10, true)
.await
@@ -8938,6 +11384,8 @@
.unwrap();
}
let wire = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 1,
next_offset: 0,
consumers: vec![(7, 5)],
@@ -9101,7 +11549,7 @@
let dir = tempfile::tempdir().unwrap();
let (mut partition, sent) = recording_partition_at(0, 3);
partition.consumer_offsets_path = Some(dir.path().to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
let pending =
PendingConsumerOffsetCommit::upsert_auto_commit(ConsumerKind::Consumer, 7, 10);
partition
@@ -9171,7 +11619,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
);
partition.stats.increment_messages_count(1);
partition.set_consumer_offsets_max(2);
@@ -9806,6 +12253,50 @@
let _ = std::fs::remove_dir_all(&dir);
}
+ #[compio::test]
+ async fn wal_backed_cold_offsets_skip_covered_values_and_write_advances() {
+ let (directory, partition, _) = partition_with_pending_durable_ack().await;
+ let path = directory.path().join("offset");
+ let path = path.to_str().unwrap();
+ persist_offset(path, 114, false).await.unwrap();
+ assert_eq!(
+ partition
+ .write_cold_consumer_offset(path, 109, true)
+ .await
+ .unwrap(),
+ (114, false)
+ );
+ assert_eq!(
+ partition
+ .write_cold_consumer_offset(path, 114, true)
+ .await
+ .unwrap(),
+ (114, false)
+ );
+ assert!(
+ partition
+ .persistence
+ .as_ref()
+ .unwrap()
+ .take_offset_file(path)
+ .is_none()
+ );
+ assert_eq!(
+ partition
+ .write_cold_consumer_offset(path, 115, true)
+ .await
+ .unwrap(),
+ (115, true)
+ );
+ assert_eq!(
+ crate::offset_storage::read_offset_max(path, 0)
+ .await
+ .unwrap()
+ .offset,
+ 115
+ );
+ }
+
/// The persisted-offset tracker is cold after a restart; the first
/// auto-commit folds against the file once (so a pre-existing higher value
/// wins, exactly like the old per-commit read-modify-write) and warms the
@@ -10076,7 +12567,7 @@
Some(dir.path().join("consumers").to_string_lossy().into_owned());
partition.consumer_group_offsets_path =
Some(dir.path().join("groups").to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
// Visibility alone cannot satisfy the explicitly requested barrier.
partition.consumer_offset_dir_sync_fault.set(Some(0));
partition.stats.increment_messages_count(1);
@@ -10119,7 +12610,7 @@
Some(dir.path().join("consumers").to_string_lossy().into_owned());
partition.consumer_group_offsets_path =
Some(dir.path().join("groups").to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
// Dirt a NoAck request left on the groups directory, whose sync now
// fails. This walk writes consumer offsets only.
partition.consumer_offset_dirs_dirty[1].set(true);
@@ -10187,7 +12678,7 @@
let dir = tempfile::tempdir().unwrap();
let (mut partition, sent) = recording_partition();
partition.consumer_offsets_path = Some(dir.path().to_string_lossy().into_owned());
- partition.consumer_offset_enforce_fsync = true;
+ partition.runtime_options.consumer_offset_durability = iggy_common::Durability::Persisted;
let stored = PendingConsumerOffsetCommit::upsert(ConsumerKind::Consumer, 7, 0);
partition
.persist_consumer_offset_commit(stored)
@@ -10255,13 +12746,21 @@
/// stamped at `base_offset`, with a valid batch checksum so it decodes
/// through `decode_batch_slice` and matches an `Offset` poll.
pub(super) fn build_segment_record(namespace: IggyNamespace, base_offset: u64) -> Vec<u8> {
+ build_segment_record_with_payload(namespace, base_offset, Bytes::from_static(b"abcdefgh"))
+ }
+
+ fn build_segment_record_with_payload(
+ namespace: IggyNamespace,
+ base_offset: u64,
+ payload: Bytes,
+ ) -> Vec<u8> {
let mut batch = IggyMessages::with_capacity(1);
batch.push(IggyMessage {
header: IggyMessageHeader {
- payload_length: 8,
+ payload_length: u32::try_from(payload.len()).unwrap(),
..Default::default()
},
- payload: Bytes::from_static(b"abcdefgh"),
+ payload,
user_headers: None,
});
let mut owned =
@@ -11051,8 +13550,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: IggyByteSize::from(1024 * 1024),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: IggyByteSize::from(1024 * 1024),
preallocate_segments: false,
@@ -11126,7 +13624,7 @@
);
let ops: Vec<u64> = partition
- .collect_committable_from_journal(COMMIT_WALK_OPS_MAX)
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX, &repair_config())
.into_iter()
.map(|entry| entry.header.op)
.collect();
@@ -11150,13 +13648,81 @@
assert!(partition.consensus.pipeline_head_header().is_none());
let ops: Vec<u64> = partition
- .collect_committable_from_journal(COMMIT_WALK_OPS_MAX)
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX, &repair_config())
.into_iter()
.map(|entry| entry.header.op)
.collect();
assert_eq!(ops, vec![1, 2, 3], "a backup walks its whole committed run");
}
+ #[compio::test]
+ async fn persisted_commit_walk_preserves_pipeline_gaps_and_wal_ceiling() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut partition = partition_at_view(0, 0);
+ partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability = iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+ let mut parent = 0;
+ for op in 1..=4 {
+ let prepare = checksummed_segment_prepare(op, parent, op - 1, b"payload");
+ parent = prepare.header().checksum;
+ if op >= 3 {
+ partition
+ .consensus
+ .pipeline_message(PlaneKind::Partitions, &prepare);
+ }
+ let frozen = prepare.into_frozen();
+ if op <= 3 {
+ persistence.append(frozen.clone(), true).unwrap();
+ }
+ partition.log.journal().inner.append(frozen).await.unwrap();
+ }
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ persistence.exhaust_capacity_for_test();
+ partition.consensus.restore_commit_state(0, 4);
+
+ assert!(
+ partition
+ .drain_persistable_commits(&repair_config())
+ .is_empty()
+ );
+ let journaled =
+ partition.collect_committable_from_journal(COMMIT_WALK_OPS_MAX, &repair_config());
+ assert_eq!(
+ journaled
+ .iter()
+ .map(|entry| entry.header.op)
+ .collect::<Vec<_>>(),
+ vec![1, 2]
+ );
+ partition.consensus.advance_commit_min(1);
+ partition.consensus.advance_commit_min(2);
+ let drained = partition.drain_persistable_commits(&repair_config());
+ assert_eq!(
+ drained
+ .iter()
+ .map(|entry| entry.header.op)
+ .collect::<Vec<_>>(),
+ vec![3]
+ );
+ partition.consensus.advance_commit_min(3);
+ assert!(
+ partition
+ .drain_persistable_commits(&repair_config())
+ .is_empty()
+ );
+ assert_eq!(partition.consensus.pipeline_head_header().unwrap().op, 4);
+ assert!(
+ partition
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX, &repair_config())
+ .is_empty()
+ );
+ persistence.retire();
+ }
+
fn pipeline_prepare(op: u64, operation: Operation) -> Message<PrepareHeader> {
let size = std::mem::size_of::<PrepareHeader>();
Message::<PrepareHeader>::new(size).transmute_header(|_, header: &mut PrepareHeader| {
@@ -11598,6 +14164,8 @@
}
let behind = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 50,
consumers: Vec::new(),
@@ -11626,6 +14194,8 @@
// off the metadata plane (0 here), not past this replica's applied
// value, whose `purge.gen` hydration a kill-before-record leaves stale.
let purged = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 1,
next_offset: 0,
consumers: Vec::new(),
@@ -11668,6 +14238,8 @@
);
let stale = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 1_000,
consumers: Vec::new(),
@@ -11720,6 +14292,8 @@
);
let offer = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 1,
next_offset: 50,
consumers: Vec::new(),
@@ -11770,6 +14344,8 @@
);
let reset = crate::state_transfer::ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 1,
next_offset: 0,
consumers: Vec::new(),
@@ -13065,6 +15641,23 @@
}
partition.consensus().advance_commit_max(OPS);
partition.commit_journal(&repair_config()).await;
+ assert_eq!(partition.consensus().commit_min(), OPS - 1);
+ assert_eq!(
+ partition.log.active_segment().size.as_bytes_u64(),
+ record_len * (OPS - 1)
+ );
+ assert_eq!(
+ partition
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX, &repair_config())
+ .iter()
+ .map(|entry| entry.header.op)
+ .collect::<Vec<_>>(),
+ vec![OPS]
+ );
+ partition
+ .flush_committed_messages(&repair_config())
+ .await
+ .unwrap();
assert_eq!(
partition.log.active_segment().size.as_bytes_u64(),
record_len * OPS,
diff --git a/core/partitions/src/iggy_partitions.rs b/core/partitions/src/iggy_partitions.rs
index d71819e..96dffd4 100644
--- a/core/partitions/src/iggy_partitions.rs
+++ b/core/partitions/src/iggy_partitions.rs
@@ -109,6 +109,7 @@
/// suffice; callers must not hold a borrow across `.await`.
tombstoned: RefCell<AHashSet<IggyNamespace>>,
consumer_group_offsets_reconcile_epoch: Rc<Cell<u64>>,
+ persistence_notifier: RefCell<Option<crate::PersistenceNotifier>>,
/// Debug-only tripwire: counts live [`Self::with_partition`] borrows so
/// `insert` / `remove` can assert the partitions vec is never mutated
/// while a sanctioned non-pump read borrow is outstanding. Cannot fire for
@@ -133,6 +134,7 @@
namespace_to_local: UnsafeCell::new(BTreeMap::new()),
tombstoned: RefCell::new(AHashSet::new()),
consumer_group_offsets_reconcile_epoch: Rc::new(Cell::new(0)),
+ persistence_notifier: RefCell::new(None),
#[cfg(debug_assertions)]
borrow_active: Cell::new(0),
}
@@ -148,11 +150,21 @@
namespace_to_local: UnsafeCell::new(BTreeMap::new()),
tombstoned: RefCell::new(AHashSet::new()),
consumer_group_offsets_reconcile_epoch: Rc::new(Cell::new(0)),
+ persistence_notifier: RefCell::new(None),
#[cfg(debug_assertions)]
borrow_active: Cell::new(0),
}
}
+ pub fn set_persistence_notifier(&self, notifier: crate::PersistenceNotifier) {
+ for namespace in self.namespaces() {
+ if let Some(partition) = self.get_by_ns(namespace) {
+ partition.set_persistence_notifier(Rc::clone(¬ifier));
+ }
+ }
+ *self.persistence_notifier.borrow_mut() = Some(notifier);
+ }
+
pub const fn config(&self) -> &PartitionsConfig {
&self.config
}
@@ -238,6 +250,9 @@
0,
"IggyPartitions::insert while a with_partition borrow is live"
);
+ if let Some(notifier) = self.persistence_notifier.borrow().as_ref() {
+ partition.set_persistence_notifier(Rc::clone(notifier));
+ }
partition.publish_current_offset();
partition.set_consumer_group_offsets_reconcile_epoch(Rc::clone(
&self.consumer_group_offsets_reconcile_epoch,
@@ -790,7 +805,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
)
}
@@ -812,7 +826,6 @@
Arc::new(PartitionStats::default()),
consensus,
IggyByteSize::from(1024 * 1024),
- false,
)
}
diff --git a/core/partitions/src/install_backup.rs b/core/partitions/src/install_backup.rs
new file mode 100644
index 0000000..37a9e2b
--- /dev/null
+++ b/core/partitions/src/install_backup.rs
@@ -0,0 +1,213 @@
+// 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 journal::durable_storage::{DiskStorage, DurableFile, DurableStorage, OpenMode};
+use std::io;
+use std::path::Path;
+
+const BACKUP: &str = ".install-backup";
+const BUILDING: &str = ".install-building";
+const RETIRED: &str = ".install-retired";
+
+/// Recover an interrupted install before opening any partition files.
+///
+/// # Errors
+/// Returns an error if the previous materialization cannot be restored durably.
+pub async fn recover(directory: &Path) -> io::Result<()> {
+ recover_with_storage(directory, &DiskStorage).await
+}
+
+/// # Errors
+/// Returns an error when the install transaction cannot complete durably.
+pub async fn recover_with_storage<S: DurableStorage>(
+ directory: &Path,
+ storage: &S,
+) -> io::Result<()> {
+ storage.remove_tree(&directory.join(BUILDING)).await?;
+ storage.remove_tree(&directory.join(RETIRED)).await?;
+ let backup = directory.join(BACKUP);
+ if !storage.exists(&backup).await? {
+ return Ok(());
+ }
+ for entry in storage.entries(directory).await? {
+ if entry.name == BACKUP {
+ continue;
+ }
+ storage.remove_tree(&directory.join(&entry.name)).await?;
+ }
+ link_tree(&backup, directory, false, storage).await?;
+ finish_with_storage(directory, storage).await
+}
+
+/// Freeze the old materialization and WAL together before a destructive install.
+/// The caller drains persistence and holds the partition write lock until finish.
+///
+/// # Errors
+/// Returns an error if the rollback state cannot be made durable.
+/// After any failure the caller must stop serving until recovery.
+pub async fn begin(directory: &Path) -> io::Result<()> {
+ begin_with_storage(directory, &DiskStorage).await
+}
+
+/// # Errors
+/// Returns an error when the install transaction cannot complete durably.
+pub async fn begin_with_storage<S: DurableStorage>(
+ directory: &Path,
+ storage: &S,
+) -> io::Result<()> {
+ if storage.exists(&directory.join(BACKUP)).await? {
+ return Err(io::Error::other("partition install recovery is pending"));
+ }
+ let building = directory.join(BUILDING);
+ storage.remove_tree(&building).await?;
+ storage.remove_tree(&directory.join(RETIRED)).await?;
+ storage.create_directories(&building).await?;
+ link_tree(directory, &building, true, storage).await?;
+ storage.rename(&building, &directory.join(BACKUP)).await?;
+ storage.sync_directory(directory).await
+}
+
+/// Publish a completed install before the group can resume voting or serving.
+///
+/// # Errors
+/// Returns an error if the installation cannot be published durably.
+pub async fn finish(directory: &Path) -> io::Result<()> {
+ finish_with_storage(directory, &DiskStorage).await
+}
+
+/// # Errors
+/// Returns an error when the install transaction cannot complete durably.
+pub async fn finish_with_storage<S: DurableStorage>(
+ directory: &Path,
+ storage: &S,
+) -> io::Result<()> {
+ let retired = directory.join(RETIRED);
+ storage.rename(&directory.join(BACKUP), &retired).await?;
+ storage.sync_directory(directory).await?;
+ // The durable rename is the commit point. Cleanup never changes recovery.
+ if let Err(error) = storage.remove_tree(&retired).await {
+ tracing::warn!(%error, path = %retired.display(), "cannot remove completed install backup");
+ }
+ Ok(())
+}
+
+async fn link_tree<S: DurableStorage>(
+ source: &Path,
+ target: &Path,
+ skip_scratch: bool,
+ storage: &S,
+) -> io::Result<()> {
+ let mut pending = vec![(source.to_path_buf(), target.to_path_buf())];
+ let mut directories = Vec::new();
+ while let Some((source, target)) = pending.pop() {
+ directories.push(target.clone());
+ for entry in storage.entries(&source).await? {
+ let name = entry.name;
+ if skip_scratch && is_scratch(&name.to_string_lossy()) {
+ continue;
+ }
+ let destination = target.join(&name);
+ if entry.directory {
+ storage.create_directories(&destination).await?;
+ pending.push((source.join(&name), destination));
+ } else {
+ // Transfer unlinks or atomically replaces these frozen files.
+ // Hard links retain the old bytes without copying segment data.
+ storage.hard_link(&source.join(&name), &destination).await?;
+ storage
+ .open(&destination, OpenMode::Read)
+ .await?
+ .sync()
+ .await?;
+ }
+ }
+ }
+ for directory in directories.into_iter().rev() {
+ storage.sync_directory(&directory).await?;
+ }
+ Ok(())
+}
+
+fn is_scratch(name: &str) -> bool {
+ matches!(name, BACKUP | BUILDING | RETIRED)
+ || Path::new(name)
+ .extension()
+ .is_some_and(|extension| extension == "staging" || extension == "tmp")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use compio::fs::File;
+
+ #[compio::test]
+ async fn interrupted_install_restores_segments_offsets_and_wal_together() {
+ let directory = tempfile::tempdir().unwrap();
+ let root = directory.path();
+ std::fs::create_dir(root.join("prepares-1")).unwrap();
+ std::fs::create_dir(root.join("offsets")).unwrap();
+ for name in [
+ "0.log",
+ "0.index",
+ "superblock.a",
+ "offsets/1",
+ "prepares-1/frontier",
+ "prepares-1/prepares-0.wal",
+ ] {
+ std::fs::write(root.join(name), name.as_bytes()).unwrap();
+ }
+ begin(root).await.unwrap();
+ // Model the install's unlink and atomic replacement operations.
+ for name in ["0.log", "offsets/1", "prepares-1/frontier"] {
+ std::fs::remove_file(root.join(name)).unwrap();
+ std::fs::write(root.join(name), b"replacement").unwrap();
+ }
+ std::fs::write(root.join("99.log"), b"new segment").unwrap();
+ recover(root).await.unwrap();
+ for name in [
+ "0.log",
+ "0.index",
+ "superblock.a",
+ "offsets/1",
+ "prepares-1/frontier",
+ "prepares-1/prepares-0.wal",
+ ] {
+ assert_eq!(std::fs::read(root.join(name)).unwrap(), name.as_bytes());
+ }
+ assert!(!root.join("99.log").exists());
+ recover(root).await.unwrap();
+ }
+
+ #[compio::test]
+ async fn completed_install_never_restores_the_old_materialization() {
+ let directory = tempfile::tempdir().unwrap();
+ let root = directory.path();
+ std::fs::write(root.join("0.log"), b"old").unwrap();
+ begin(root).await.unwrap();
+ std::fs::remove_file(root.join("0.log")).unwrap();
+ std::fs::write(root.join("0.log"), b"new").unwrap();
+ File::open(root.join("0.log"))
+ .await
+ .unwrap()
+ .sync_all()
+ .await
+ .unwrap();
+ finish(root).await.unwrap();
+ recover(root).await.unwrap();
+ assert_eq!(std::fs::read(root.join("0.log")).unwrap(), b"new");
+ }
+}
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 1bffcd1..0afb994 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -262,16 +262,11 @@
/// storage never hits the reactor (it copies from an in-memory `Vec`), so
/// the read can run under a partition borrow without crossing an `.await`
/// - the property that keeps poll-read sound.
- fn read_at_sync(&self, offset: usize) -> JournalBuffer {
+ fn read_at_sync(&self, offset: usize) -> Option<JournalBuffer> {
let offset_to_index = unsafe { &*self.offset_to_index.get() };
- let Some(&index) = offset_to_index.get(&offset) else {
- return Owned::<4096>::zeroed(0).into();
- };
+ let index = *offset_to_index.get(&offset)?;
let entries = unsafe { &*self.entries.get() };
- entries
- .get(index)
- .cloned()
- .unwrap_or_else(|| Owned::<4096>::zeroed(0).into())
+ entries.get(index).cloned()
}
fn entries(&self) -> Vec<JournalBuffer> {
@@ -351,6 +346,14 @@
op_to_storage_offset.len()
}
+ /// Restore the materialized commit point for elections and body repair.
+ pub fn restore_checkpoint_prepare(&self, op: u64, prepare: JournalBuffer) {
+ let ring = unsafe { &mut *self.evicted_ring.get() };
+ debug_assert!(ring.is_empty());
+ self.evicted_ring_bytes.set(prepare.len() as u64);
+ ring.push_back((op, prepare));
+ }
+
/// Entry bytes for `op`, from the resident journal or the evicted ring.
/// `None` when the op predates the ring (bulk-sync territory) or was
/// never journaled here.
@@ -359,7 +362,7 @@
let op_to_storage_offset = unsafe { &*self.op_to_storage_offset.get() };
if let Some(&storage_offset) = op_to_storage_offset.get(&op) {
let inner = unsafe { &*self.inner.get() };
- return Some(inner.storage.read_at_sync(storage_offset));
+ return inner.storage.read_at_sync(storage_offset);
}
}
let ring = unsafe { &*self.evicted_ring.get() };
@@ -476,7 +479,10 @@
let bytes = {
let inner = unsafe { &*self.inner.get() };
- inner.storage.read_at_sync(storage_offset)
+ let Some(bytes) = inner.storage.read_at_sync(storage_offset) else {
+ break;
+ };
+ bytes
};
try_push_resident_entry(
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 36be808..71cc995 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -23,11 +23,16 @@
mod iggy_index_writer;
mod iggy_partition;
mod iggy_partitions;
+pub mod install_backup;
mod journal;
mod log;
mod messages_writer;
pub mod offset_storage;
+mod persistence;
mod poll_plan;
+pub use persistence::{
+ PartitionPersistence, PersistenceCompletion, PersistenceMetrics, PersistenceNotifier,
+};
mod segment;
pub mod segment_anchor;
pub mod state_transfer;
diff --git a/core/partitions/src/messages_writer.rs b/core/partitions/src/messages_writer.rs
index 63ad93c..89dcb3c 100644
--- a/core/partitions/src/messages_writer.rs
+++ b/core/partitions/src/messages_writer.rs
@@ -20,15 +20,14 @@
io::AsyncWriteAtExt,
};
use iggy_common::{IggyByteSize, IggyError};
+use server_common::fs_utils::preallocate_file;
use server_common::iobuf::{Frozen, IOV_MAX};
use std::{
+ path::Path,
rc::Rc,
sync::atomic::{AtomicU64, Ordering},
};
-use tracing::{error, warn};
-
-#[cfg(target_os = "linux")]
-use nix::fcntl::{FallocateFlags, fallocate};
+use tracing::error;
#[derive(Debug)]
pub struct MessagesWriter {
@@ -63,7 +62,7 @@
.map_err(|_| IggyError::CannotReadFile)?;
if let Some(preallocate_size) = preallocate_size {
- preallocate_file(&file, file_path, preallocate_size.as_bytes_u64());
+ preallocate_file(&file, Path::new(file_path), preallocate_size.as_bytes_u64());
}
if file_exists {
@@ -160,55 +159,6 @@
}
}
-#[cfg(target_os = "linux")]
-fn preallocate_file(file: &File, file_path: &str, len: u64) {
- let Ok(len) = i64::try_from(len) else {
- warn!(
- target: "iggy.partitions.storage",
- file = file_path,
- preallocate_len = len,
- "file preallocation size is unsupported, using buffered allocation"
- );
- return;
- };
-
- // Runs INLINE on the shard thread, deliberately. `server_common::executor`
- // sets `thread_pool_limit(0)` on the shard proactor, so `spawn_blocking`
- // has no worker to park a task on and compio panics the shard outright with
- // "the thread pool is needed but no worker thread is running". (That limit
- // is skipped on macOS, whose polling driver routes fs through the pool, so
- // the panic is Linux-and-most-targets, not universal. This arm is
- // Linux-only regardless.)
- //
- // The cost is acceptable only because of what this call is: a metadata-only
- // extent reservation, microseconds on the local filesystems this option
- // exists for, and an immediate `EOPNOTSUPP` where the filesystem cannot do
- // it. Where it can genuinely block -- NFSv4.2 `ALLOCATE`, FUSE, a badly
- // fragmented extent tree forcing a journal commit -- it stalls the whole
- // core, not one partition, because nothing here yields. Preallocation is
- // opt-in per topic at creation for that reason; on such a deployment,
- // create topics without `preallocate_segments` rather than reintroducing a
- // pool the shard runtime does not have.
- if let Err(error) = fallocate(file, FallocateFlags::FALLOC_FL_KEEP_SIZE, 0, len) {
- warn!(
- target: "iggy.partitions.storage",
- file = file_path,
- preallocate_len = len,
- %error,
- "file preallocation failed, using buffered allocation"
- );
- }
-}
-
-#[cfg(not(target_os = "linux"))]
-fn preallocate_file(_file: &File, file_path: &str, _len: u64) {
- warn!(
- target: "iggy.partitions.storage",
- file = file_path,
- "file preallocation is unavailable on this platform, using buffered allocation"
- );
-}
-
async fn write_frozen_chunked<const ALIGN: usize>(
file: &File,
file_path: &str,
diff --git a/core/partitions/src/offset_storage.rs b/core/partitions/src/offset_storage.rs
index f750b13..aaf3414 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -20,7 +20,7 @@
io::{AsyncReadAt, AsyncReadAtExt, AsyncWriteAtExt},
};
use iggy_common::{IggyError, calculate_checksum};
-use std::path::Path;
+use std::{io, path::Path};
use tracing::warn;
const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
@@ -52,7 +52,7 @@
/// checksum, read as-is and upgraded by the next write.
Value { offset: u64, checksummed: bool },
/// Shorter than the value: a crash between the truncate and the write of an
- /// in-place update, the default path while `consumer_offset_enforce_fsync`
+ /// in-place update, the default path while `consumer_offset_persisted`
/// is off.
Torn,
/// The checksum does not describe the value stored beside it.
@@ -111,7 +111,7 @@
/// Overwrite a consumer-offset file with `offset` and a checksum over it.
///
-/// Without `enforce_fsync` the file is rewritten in place and no directory is
+/// Without `persisted` the file is rewritten in place and no directory is
/// synced. With it, the record goes to a sibling inode, is data-synced and
/// renamed over the prior file, so a failed write leaves the prior cursor
/// intact, and the caller marks the parent directory for a sync on the next
@@ -121,15 +121,45 @@
///
/// # Errors
/// [`IggyError`] when the directory, file, or write cannot be created or completed.
-pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Result<(), IggyError> {
+pub async fn persist_offset(path: &str, offset: u64, persisted: bool) -> Result<(), IggyError> {
let record = encode_offset_record(offset);
- if enforce_fsync {
+ if persisted {
replace_file(path, record, true, false).await
} else {
write_in_place(path, record).await
}
}
+/// Return the write result with its original descriptor so checkpoint observes
+/// writeback errors even after an unsuccessful write.
+///
+/// No barrier runs here. For either offset durability policy, the caller must
+/// retain the writer and sync it and its directory before reclaiming the WAL
+/// history that protects the update.
+///
+/// # Errors
+/// The outer error reports directory/open failures before writing begins.
+pub async fn persist_offset_retained(
+ path: &str,
+ offset: u64,
+ existing: Option<compio::fs::File>,
+) -> Result<(io::Result<()>, compio::fs::File), IggyError> {
+ let mut file = if let Some(file) = existing {
+ file
+ } else {
+ create_parent_dir(path).await?;
+ OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(path)
+ .await
+ .map_err(|_| IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?
+ };
+ let result = file.write_all_at(encode_offset_record(offset), 0).await.0;
+ Ok((result, file))
+}
+
async fn write_in_place<const N: usize>(path: &str, record: [u8; N]) -> Result<(), IggyError> {
create_parent_dir(path).await?;
let mut file = OpenOptions::new()
@@ -160,7 +190,7 @@
pub(crate) async fn stage_offset_replacement(path: &str, offset: u64) -> Result<(), IggyError> {
// Install can remove old files before publishing replacements. Staging
- // must survive a crash regardless of the normal consumer-offset fsync knob.
+ // must survive a crash regardless of the normal consumer-offset durability policy.
write_replacement(path, encode_offset_record(offset), true)
.await
.map(|_| ())
@@ -179,10 +209,10 @@
async fn replace_file<const N: usize>(
path: &str,
record: [u8; N],
- enforce_fsync: bool,
+ persisted: bool,
sync_parent: bool,
) -> Result<(), IggyError> {
- let temporary = write_replacement(path, record, enforce_fsync).await?;
+ let temporary = write_replacement(path, record, persisted).await?;
if rename(&temporary, path).await.is_err() {
let _ = remove_file(&temporary).await;
return Err(IggyError::CannotWriteToFile);
@@ -203,7 +233,7 @@
async fn write_replacement<const N: usize>(
path: &str,
record: [u8; N],
- enforce_fsync: bool,
+ persisted: bool,
) -> Result<String, IggyError> {
create_parent_dir(path).await?;
@@ -224,7 +254,7 @@
return Err(IggyError::CannotWriteToFile);
}
- if enforce_fsync && file.sync_data().await.is_err() {
+ if persisted && file.sync_data().await.is_err() {
let _ = remove_file(&temporary).await;
return Err(IggyError::CannotWriteToFile);
}
@@ -271,8 +301,20 @@
pub async fn persist_offset_max(
path: &str,
offset: u64,
- enforce_fsync: bool,
+ persisted: bool,
) -> Result<PersistedOffset, IggyError> {
+ let result = read_offset_max(path, offset).await?;
+ if result.written {
+ persist_offset(path, result.offset, persisted).await?;
+ }
+ Ok(result)
+}
+
+/// Read the committed maximum without replacing its file.
+///
+/// # Errors
+/// Returns an error if the existing offset cannot be read.
+pub async fn read_offset_max(path: &str, offset: u64) -> Result<PersistedOffset, IggyError> {
let on_disk = match read_offset_record(path).await? {
Some(OffsetRecord::Value { offset, .. }) => Some(offset),
Some(OffsetRecord::Corrupt {
@@ -295,9 +337,6 @@
};
let effective = on_disk.map_or(offset, |current| current.max(offset));
let written = on_disk != Some(effective);
- if written {
- persist_offset(path, effective, enforce_fsync).await?;
- }
Ok(PersistedOffset {
offset: effective,
written,
@@ -308,7 +347,7 @@
/// to the incarnation (`created_revision`) it was applied for.
///
/// Atomic replacement like [`persist_offset`] but ALWAYS data-synced, regardless of
-/// the consumer-offset fsync knob: purges are rare, the record is 16 bytes, and
+/// the consumer-offset durability policy: purges are rare, the record is 16 bytes, and
/// a generation lost from the page cache in a crash makes the reconciler
/// re-purge on restart, wiping messages appended after the purge. A failure
/// leaves the previous record on disk so the caller keeps its in-memory
diff --git a/core/partitions/src/persistence.rs b/core/partitions/src/persistence.rs
new file mode 100644
index 0000000..57b45af
--- /dev/null
+++ b/core/partitions/src/persistence.rs
@@ -0,0 +1,1504 @@
+// 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 futures::TryStreamExt;
+use iggy_binary_protocol::{Operation, PrepareHeader};
+use journal::PartitionPrepareJournal;
+use journal::durable_storage::{DiskStorage, DurableFile, DurableStorage};
+use journal::partition_journal::{PARTITION_WAL_BYTES_MAX, SegmentPosition, SegmentReference};
+use server_common::Message;
+use server_common::iobuf::Frozen;
+use smallvec::SmallVec;
+use std::cell::{Cell, RefCell};
+use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
+use std::io;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
+use std::sync::{Arc, LazyLock, Mutex, Weak};
+use std::time::Duration;
+
+#[cfg(unix)]
+use nix::sys::resource::{Resource, getrlimit};
+
+const APPEND_BATCH_BYTES_MAX: u64 = 1024 * 1024;
+const APPEND_BATCH_OPS_MAX: usize = 64;
+const CHECKPOINT_DIRTY_FILES_MAX: usize = 1024;
+#[cfg(unix)]
+const OFFSET_FILES_TOTAL_MAX: usize = 1024;
+const OFFSET_FILES_PER_PARTITION_MAX: usize = 64;
+#[cfg(unix)]
+const OFFSET_FILE_LIMIT_DIVISOR: u64 = 4;
+const PERSISTENCE_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
+
+static NEXT_INSTANCE: AtomicU64 = AtomicU64::new(1);
+
+static RETAINED_OFFSET_FILES: AtomicUsize = AtomicUsize::new(0);
+static OFFSET_FILE_LIMIT: LazyLock<usize> = LazyLock::new(|| {
+ // Leave descriptor space for sockets, journals, indexes, and active I/O.
+ #[cfg(unix)]
+ let limit = getrlimit(Resource::RLIMIT_NOFILE).map_or(0, |(soft, _)| {
+ usize::try_from(soft / OFFSET_FILE_LIMIT_DIVISOR)
+ .unwrap_or(OFFSET_FILES_TOTAL_MAX)
+ .min(OFFSET_FILES_TOTAL_MAX)
+ });
+ #[cfg(not(unix))]
+ let limit = 0;
+ limit
+});
+
+struct RetainedOffsetFile<F> {
+ file: F,
+ _permit: OffsetFilePermit,
+}
+
+struct OffsetFilePermit;
+
+impl OffsetFilePermit {
+ fn acquire() -> Option<Self> {
+ RETAINED_OFFSET_FILES
+ .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
+ (count < *OFFSET_FILE_LIMIT).then_some(count + 1)
+ })
+ .ok()
+ .map(|_| Self)
+ }
+}
+
+impl Drop for OffsetFilePermit {
+ fn drop(&mut self) {
+ RETAINED_OFFSET_FILES.fetch_sub(1, Ordering::Relaxed);
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct PersistenceCompletion {
+ pub group: u64,
+ pub instance: u64,
+ pub epoch: u64,
+}
+
+#[derive(Default)]
+pub struct PersistenceMetrics {
+ pub disk_bytes: u64,
+ pub retained_bytes: u64,
+ pub queued_bytes: u64,
+ pub in_flight_bytes: u64,
+ pub checkpoints_pending: u64,
+ pub completed_batches: u64,
+ pub batched_prepares: u64,
+ pub completed_checkpoints: u64,
+ pub failed_writes: u64,
+}
+
+pub type PersistenceNotifier = Rc<dyn Fn(PersistenceCompletion)>;
+
+pub struct PartitionPersistence<S: DurableStorage = DiskStorage> {
+ group: u64,
+ instance: u64,
+ lease: Option<Arc<WriterLease>>,
+ epoch: Cell<u64>,
+ journal: RefCell<Option<PartitionPrepareJournal<S>>>,
+ queue: RefCell<VecDeque<Mutation<S>>>,
+ offset_files: RefCell<HashMap<String, RetainedOffsetFile<S::File>>>,
+ retired_offset_files: RefCell<Vec<RetainedOffsetFile<S::File>>>,
+ accepted: RefCell<AcceptedPrepares>,
+ // Published with written_head so readers never borrow the journal across writer I/O.
+ segment_references: RefCell<BTreeMap<u64, SegmentReference>>,
+ accepted_head: Cell<u64>,
+ written_head: Cell<u64>,
+ durable_head: Cell<u64>,
+ checkpoint: Cell<u64>,
+ checkpoint_checksum: Cell<Option<u128>>,
+ certified_log_view: Cell<Option<u32>>,
+ requested_log_view: Cell<Option<(u32, u64, u128)>>,
+ checkpoint_requested: Cell<u64>,
+ checkpoint_running: Cell<bool>,
+ checkpoint_needed: Cell<bool>,
+ dirty_segments: RefCell<BTreeSet<u64>>,
+ dirty_offsets: [RefCell<BTreeSet<u32>>; 2],
+ purge_generation: Cell<u64>,
+ purge_floor: Cell<u64>,
+ capacity: u64,
+ disk_bytes: Cell<u64>,
+ retained_bytes: Cell<u64>,
+ segment_checkpoint: Cell<Option<SegmentPosition>>,
+ queued_bytes: Cell<u64>,
+ in_flight_bytes: Cell<u64>,
+ waiters: RefCell<Vec<std::task::Waker>>,
+ running: Cell<bool>,
+ writer_active: Cell<bool>,
+ retired: Cell<bool>,
+ failure: RefCell<Option<Arc<io::Error>>>,
+ failure_operation: Cell<Operation>,
+ notifier: RefCell<Option<PersistenceNotifier>>,
+ completed_batches: Cell<u64>,
+ batched_prepares: Cell<u64>,
+ completed_checkpoints: Cell<u64>,
+ failed_writes: Cell<u64>,
+}
+
+struct WriterLease {
+ key: PathBuf,
+ id: u64,
+ retired: AtomicBool,
+ running: AtomicBool,
+ waiters: Mutex<Vec<std::task::Waker>>,
+}
+
+struct WriterRegistration {
+ id: u64,
+ writer: Weak<WriterLease>,
+ interrupted: bool,
+}
+
+static WRITERS: LazyLock<Mutex<HashMap<PathBuf, WriterRegistration>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+impl WriterLease {
+ async fn acquire(key: PathBuf) -> io::Result<Arc<Self>> {
+ loop {
+ let previous = {
+ let mut writers = WRITERS
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if writers
+ .get(&key)
+ .is_some_and(|registration| registration.interrupted)
+ {
+ return Err(io::Error::other(
+ "interrupted partition WAL writer requires process restart",
+ ));
+ }
+ if let Some(previous) = writers
+ .get(&key)
+ .and_then(|registration| registration.writer.upgrade())
+ {
+ drop(writers);
+ Some(previous)
+ } else {
+ let lease = Arc::new(Self {
+ key: key.clone(),
+ id: NEXT_INSTANCE.fetch_add(1, Ordering::Relaxed),
+ retired: AtomicBool::new(false),
+ running: AtomicBool::new(false),
+ waiters: Mutex::new(Vec::new()),
+ });
+ writers.insert(
+ key.clone(),
+ WriterRegistration {
+ id: lease.id,
+ writer: Arc::downgrade(&lease),
+ interrupted: false,
+ },
+ );
+ drop(writers);
+ return Ok(lease);
+ }
+ };
+ let Some(previous) = previous else { continue };
+ if !previous.retired.load(Ordering::Acquire) {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "partition WAL already has an active writer",
+ ));
+ }
+ compio::runtime::time::timeout(
+ PERSISTENCE_DRAIN_TIMEOUT,
+ futures::future::poll_fn(|context| {
+ let mut waiters = previous
+ .waiters
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if !previous.running.load(Ordering::Acquire) {
+ return std::task::Poll::Ready(());
+ }
+ if !waiters
+ .iter()
+ .any(|waiter| waiter.will_wake(context.waker()))
+ {
+ waiters.push(context.waker().clone());
+ }
+ std::task::Poll::Pending
+ }),
+ )
+ .await
+ .map_err(|_| {
+ io::Error::new(
+ io::ErrorKind::TimedOut,
+ "retired partition WAL writer did not stop",
+ )
+ })?;
+ let mut writers = WRITERS
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if writers.get(&key).is_some_and(|registration| {
+ registration.id == previous.id && !registration.interrupted
+ }) {
+ writers.remove(&key);
+ }
+ }
+ }
+
+ fn finish(&self) {
+ self.running.store(false, Ordering::Release);
+ for waiter in self
+ .waiters
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .drain(..)
+ {
+ waiter.wake();
+ }
+ }
+}
+
+impl Drop for WriterLease {
+ fn drop(&mut self) {
+ let mut writers = WRITERS
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if writers
+ .get(&self.key)
+ .is_some_and(|registration| registration.id == self.id && !registration.interrupted)
+ {
+ writers.remove(&self.key);
+ }
+ }
+}
+
+struct WriterTicket<S: DurableStorage> {
+ owner: Rc<PartitionPersistence<S>>,
+ started: bool,
+}
+
+impl<S: DurableStorage> Drop for WriterTicket<S> {
+ fn drop(&mut self) {
+ if self.started {
+ return;
+ }
+ self.owner.fail(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "partition WAL writer was dropped before polling",
+ ));
+ self.owner.running.set(false);
+ if let Some(lease) = &self.owner.lease {
+ lease.finish();
+ }
+ for waiter in self.owner.waiters.borrow_mut().drain(..) {
+ waiter.wake();
+ }
+ }
+}
+
+struct WriterGuard<'a, S: DurableStorage> {
+ owner: &'a PartitionPersistence<S>,
+ journal: Option<PartitionPrepareJournal<S>>,
+ complete: bool,
+}
+
+impl<S: DurableStorage> Drop for WriterGuard<'_, S> {
+ fn drop(&mut self) {
+ *self.owner.journal.borrow_mut() = self.journal.take();
+ self.owner.in_flight_bytes.set(0);
+ self.owner.checkpoint_running.set(false);
+ if !self.complete
+ && let Some(lease) = &self.owner.lease
+ {
+ let mut writers = WRITERS
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if let Some(registration) = writers.get_mut(&lease.key)
+ && registration.id == lease.id
+ {
+ registration.interrupted = true;
+ }
+ }
+ if !self.complete && self.owner.failure.borrow().is_none() {
+ *self.owner.failure.borrow_mut() = Some(Arc::new(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "partition WAL writer stopped during a mutation",
+ )));
+ }
+ self.owner.writer_active.set(false);
+ self.owner.running.set(false);
+ if let Some(lease) = &self.owner.lease {
+ lease.finish();
+ }
+ for waiter in self.owner.waiters.borrow_mut().drain(..) {
+ waiter.wake();
+ }
+ }
+}
+
+struct AcceptedPrepares {
+ base: u64,
+ checksums: VecDeque<u128>,
+}
+
+impl AcceptedPrepares {
+ fn checksum(&self, op: u64) -> Option<u128> {
+ let index = usize::try_from(op.checked_sub(self.base)?.checked_sub(1)?).ok()?;
+ self.checksums.get(index).copied()
+ }
+
+ fn truncate_from(&mut self, op: u64) {
+ let keep =
+ usize::try_from(op.saturating_sub(self.base).saturating_sub(1)).unwrap_or(usize::MAX);
+ self.checksums.truncate(keep);
+ }
+
+ fn checkpoint(&mut self, op: u64) {
+ let count = usize::try_from(op.saturating_sub(self.base))
+ .unwrap_or(usize::MAX)
+ .min(self.checksums.len());
+ self.checksums.drain(..count);
+ self.base = op;
+ }
+}
+
+enum Mutation<S: DurableStorage> {
+ EnableSegments {
+ epoch: u64,
+ initial: SegmentPosition,
+ max_size: u64,
+ },
+ CertifyView {
+ epoch: u64,
+ view: u32,
+ op: u64,
+ checksum: u128,
+ },
+ Purge {
+ epoch: u64,
+ generation: u64,
+ floor: u64,
+ },
+ Append {
+ epoch: u64,
+ prepare: Frozen<4096>,
+ durable: bool,
+ bytes: u64,
+ },
+ Truncate {
+ epoch: u64,
+ from_op: u64,
+ },
+ Checkpoint {
+ epoch: u64,
+ through_op: u64,
+ files: Vec<PathBuf>,
+ directories: Vec<PathBuf>,
+ offset_files: Vec<RetainedOffsetFile<S::File>>,
+ synced_files: BTreeSet<PathBuf>,
+ },
+ Reset {
+ epoch: u64,
+ op: u64,
+ checksum: Option<u128>,
+ prepare: Option<Frozen<4096>>,
+ segments: Option<(SegmentPosition, u64)>,
+ },
+}
+
+impl PartitionPersistence {
+ /// # Errors
+ /// Returns an error if the partition WAL cannot be recovered.
+ pub async fn open(
+ directory: &Path,
+ group: u64,
+ incarnation: u64,
+ ) -> io::Result<(Rc<Self>, Vec<Message<PrepareHeader>>)> {
+ Self::open_with_storage(directory, group, incarnation, DiskStorage).await
+ }
+}
+
+impl<S: DurableStorage> PartitionPersistence<S> {
+ /// # Errors
+ /// Returns an error if persistence fails, capacity is exhausted, or history is invalid.
+ pub async fn open_with_storage(
+ directory: &Path,
+ group: u64,
+ incarnation: u64,
+ storage: S,
+ ) -> io::Result<(Rc<Self>, Vec<Message<PrepareHeader>>)> {
+ Self::open_with_capacity(
+ directory,
+ group,
+ incarnation,
+ storage,
+ PARTITION_WAL_BYTES_MAX,
+ false,
+ )
+ .await
+ }
+
+ /// # Errors
+ /// Returns an error for invalid capacity or unverifiable durable history.
+ pub async fn open_with_capacity(
+ directory: &Path,
+ group: u64,
+ incarnation: u64,
+ storage: S,
+ capacity: u64,
+ preallocate_segments: bool,
+ ) -> io::Result<(Rc<Self>, Vec<Message<PrepareHeader>>)> {
+ let partition_directory = directory.parent().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "partition WAL has no parent directory",
+ )
+ })?;
+ let lease = if let Some(key) = storage.writer_identity(partition_directory)? {
+ Some(WriterLease::acquire(key).await?)
+ } else {
+ None
+ };
+ let mut journal = PartitionPrepareJournal::open_with_storage_and_capacity(
+ directory,
+ group,
+ incarnation,
+ storage,
+ capacity,
+ preallocate_segments,
+ )
+ .await?;
+ let prepares = journal.take_recovered_prepares();
+ let mut accepted = AcceptedPrepares {
+ base: journal.checkpoint_op(),
+ checksums: VecDeque::with_capacity(prepares.len()),
+ };
+ for prepare in &prepares {
+ let header = prepare.header();
+ if header.op > journal.checkpoint_op() {
+ accepted.checksums.push_back(header.checksum);
+ }
+ }
+ let persistence = Rc::new(Self {
+ group,
+ instance: NEXT_INSTANCE.fetch_add(1, Ordering::Relaxed),
+ lease,
+ epoch: Cell::new(0),
+ accepted_head: Cell::new(journal.head()),
+ written_head: Cell::new(journal.head()),
+ durable_head: Cell::new(journal.head()),
+ checkpoint: Cell::new(journal.checkpoint_op()),
+ checkpoint_checksum: Cell::new(journal.checkpoint_checksum()),
+ certified_log_view: Cell::new(journal.certified_log_view()),
+ requested_log_view: Cell::new(None),
+ checkpoint_requested: Cell::new(journal.checkpoint_op()),
+ checkpoint_running: Cell::new(false),
+ checkpoint_needed: Cell::new(false),
+ dirty_segments: RefCell::new(BTreeSet::new()),
+ dirty_offsets: std::array::from_fn(|_| RefCell::new(BTreeSet::new())),
+ purge_generation: Cell::new(journal.purge_marker().0),
+ purge_floor: Cell::new(journal.purge_marker().1),
+ capacity,
+ disk_bytes: Cell::new(journal.size_bytes()),
+ retained_bytes: Cell::new(journal.retained_bytes()),
+ segment_checkpoint: Cell::new(journal.segment_checkpoint()),
+ segment_references: RefCell::new(journal.written_segment_references(0).collect()),
+ journal: RefCell::new(Some(journal)),
+ queue: RefCell::new(VecDeque::new()),
+ offset_files: RefCell::new(HashMap::new()),
+ retired_offset_files: RefCell::new(Vec::new()),
+ accepted: RefCell::new(accepted),
+ queued_bytes: Cell::new(0),
+ in_flight_bytes: Cell::new(0),
+ waiters: RefCell::new(Vec::new()),
+ running: Cell::new(false),
+ writer_active: Cell::new(false),
+ retired: Cell::new(false),
+ failure: RefCell::new(None),
+ failure_operation: Cell::new(Operation::SendMessages),
+ notifier: RefCell::new(None),
+ completed_batches: Cell::new(0),
+ batched_prepares: Cell::new(0),
+ completed_checkpoints: Cell::new(0),
+ failed_writes: Cell::new(0),
+ });
+ Ok((persistence, prepares))
+ }
+
+ #[cfg(test)]
+ pub(crate) fn exhaust_capacity_for_test(&self) {
+ self.disk_bytes.set(self.capacity);
+ self.retained_bytes.set(self.capacity);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn release_capacity_for_test(&self) {
+ self.disk_bytes.set(0);
+ self.retained_bytes.set(0);
+ }
+
+ pub const fn certified_log_view(&self) -> Option<u32> {
+ self.certified_log_view.get()
+ }
+
+ pub fn certify_log_view(&self, view: u32, op: u64, checksum: u128) -> bool {
+ if self.certified_log_view.get() == Some(view) {
+ return true;
+ }
+ if self.retired.get()
+ || self.failure.borrow().is_some()
+ || op > self.head()
+ || !(op == 0 && checksum == 0 || self.checksum(op) == Some(checksum))
+ {
+ return false;
+ }
+ let target = (view, op, checksum);
+ if self
+ .requested_log_view
+ .get()
+ .is_none_or(|(pending, _, _)| pending != view)
+ {
+ self.requested_log_view.set(Some(target));
+ self.queue.borrow_mut().push_back(Mutation::CertifyView {
+ epoch: self.epoch.get(),
+ view,
+ op,
+ checksum,
+ });
+ }
+ false
+ }
+
+ pub fn set_notifier(&self, notifier: PersistenceNotifier) {
+ *self.notifier.borrow_mut() = Some(notifier);
+ }
+
+ pub const fn accepts_completion(&self, completion: PersistenceCompletion) -> bool {
+ completion.instance == self.instance
+ && completion.epoch == self.epoch.get()
+ && !self.retired.get()
+ }
+
+ pub fn is_durable(&self, header: &PrepareHeader) -> bool {
+ self.is_written(header) && header.op <= self.durable_head.get()
+ }
+
+ pub fn is_written(&self, header: &PrepareHeader) -> bool {
+ self.is_written_through(header.op) && self.checksum(header.op) == Some(header.checksum)
+ }
+
+ pub fn is_written_through(&self, op: u64) -> bool {
+ !self.retired.get() && self.failure.borrow().is_none() && op <= self.written_head.get()
+ }
+
+ pub const fn segment_checkpoint(&self) -> Option<SegmentPosition> {
+ self.segment_checkpoint.get()
+ }
+
+ pub fn enable_segment_storage(&self, initial: SegmentPosition, max_size: u64) {
+ self.queue.borrow_mut().push_back(Mutation::EnableSegments {
+ epoch: self.epoch.get(),
+ initial,
+ max_size,
+ });
+ }
+
+ /// Verify the physical prefix before making its indexes and logical sizes visible.
+ ///
+ /// # Errors
+ /// Returns an error for an incomplete write or an unmet durability requirement.
+ pub fn validate_segment_prefix(
+ &self,
+ prepares: &[Frozen<4096>],
+ start_offset: u64,
+ mut position: u64,
+ durable: bool,
+ ) -> io::Result<u64> {
+ let references = self.segment_references.borrow();
+ let initial = position;
+ for prepare in prepares {
+ let header = prepare_header(prepare)?;
+ let reference = references
+ .get(&header.op)
+ .filter(|_| {
+ if durable {
+ self.is_durable(header)
+ } else {
+ self.is_written(header)
+ }
+ })
+ .ok_or_else(|| io::Error::other("committed segment body is not ready"))?;
+ if reference.start_offset != start_offset
+ || reference.position != position
+ || reference.length != (prepare.len() - size_of::<PrepareHeader>()) as u64
+ {
+ return Err(io::Error::other(
+ "committed prepare differs from its segment position",
+ ));
+ }
+ position = position
+ .checked_add(reference.length)
+ .ok_or_else(|| io::Error::other("segment prefix overflow"))?;
+ }
+ Ok(position - initial)
+ }
+
+ pub const fn durable_op(&self) -> u64 {
+ self.durable_head.get()
+ }
+
+ pub fn is_durable_through(&self, op: u64) -> bool {
+ self.is_written_through(op) && op <= self.durable_head.get()
+ }
+
+ pub fn has_capacity(&self, frame_bytes: usize) -> bool {
+ let Ok(bytes) = journal::partition_journal::record_length(frame_bytes) else {
+ return false;
+ };
+ let bytes = bytes as u64;
+ !self.retired.get()
+ && self.failure.borrow().is_none()
+ && self
+ .retained_bytes
+ .get()
+ .saturating_add(self.queued_bytes.get())
+ .saturating_add(self.in_flight_bytes.get())
+ .saturating_add(bytes)
+ <= self.capacity
+ }
+
+ /// # Errors
+ /// Returns an error if persistence fails, capacity is exhausted, or history is invalid.
+ pub fn append(&self, prepare: Frozen<4096>, durable: bool) -> io::Result<()> {
+ let header = prepare_header(&prepare)?;
+ let bytes = journal::partition_journal::record_length(prepare.len())? as u64;
+ if self.accepted.borrow().checksum(header.op) == Some(header.checksum) {
+ return Ok(());
+ }
+ if !self.has_capacity(prepare.len()) {
+ return Err(io::Error::new(
+ io::ErrorKind::WouldBlock,
+ "partition WAL capacity exhausted",
+ ));
+ }
+ if header.op != self.accepted_head.get().saturating_add(1) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "partition WAL submission is out of order",
+ ));
+ }
+ self.queued_bytes.set(self.queued_bytes.get() + bytes);
+ self.accepted
+ .borrow_mut()
+ .checksums
+ .push_back(header.checksum);
+ self.accepted_head.set(header.op);
+ self.queue.borrow_mut().push_back(Mutation::Append {
+ epoch: self.epoch.get(),
+ prepare,
+ durable,
+ bytes,
+ });
+ Ok(())
+ }
+
+ pub fn mark_segment_dirty(&self, start_offset: u64) {
+ self.dirty_segments.borrow_mut().insert(start_offset);
+ }
+
+ pub fn take_offset_file(&self, path: &str) -> Option<S::File> {
+ self.offset_files
+ .borrow_mut()
+ .remove(path)
+ .map(|retained| retained.file)
+ }
+
+ /// Retain the original writer or synchronize it before closing at the budget.
+ ///
+ /// # Errors
+ /// Returns a barrier error that the caller must fence like a failed write.
+ pub async fn retain_offset_file(&self, path: String, file: S::File) -> io::Result<()> {
+ let retained_count =
+ self.offset_files.borrow().len() + self.retired_offset_files.borrow().len();
+ if retained_count >= OFFSET_FILES_PER_PARTITION_MAX {
+ return file.sync().await;
+ }
+ let Some(permit) = OffsetFilePermit::acquire() else {
+ return file.sync().await;
+ };
+ if let Some(previous) = self.offset_files.borrow_mut().insert(
+ path,
+ RetainedOffsetFile {
+ file,
+ _permit: permit,
+ },
+ ) {
+ self.retired_offset_files.borrow_mut().push(previous);
+ }
+ Ok(())
+ }
+
+ pub fn retire_offset_file(&self, path: &str) {
+ if let Some(file) = self.offset_files.borrow_mut().remove(path) {
+ self.retired_offset_files.borrow_mut().push(file);
+ }
+ }
+
+ pub fn mark_offset_dirty(&self, kind_index: usize, consumer_id: u32, exists: bool) {
+ let mut offsets = self.dirty_offsets[kind_index].borrow_mut();
+ if exists {
+ offsets.insert(consumer_id);
+ } else {
+ offsets.remove(&consumer_id);
+ }
+ }
+
+ pub fn retire_offset_files(&self) {
+ self.retired_offset_files
+ .borrow_mut()
+ .extend(self.offset_files.borrow_mut().drain().map(|(_, file)| file));
+ }
+
+ pub fn take_dirty_files(&self) -> (BTreeSet<u64>, [BTreeSet<u32>; 2]) {
+ (
+ std::mem::take(&mut *self.dirty_segments.borrow_mut()),
+ std::array::from_fn(|index| {
+ std::mem::take(&mut *self.dirty_offsets[index].borrow_mut())
+ }),
+ )
+ }
+
+ pub fn checkpoint(&self, through_op: u64) {
+ self.checkpoint_files(through_op, Vec::new(), Vec::new());
+ }
+
+ pub fn checkpoint_files(
+ &self,
+ through_op: u64,
+ files: Vec<PathBuf>,
+ directories: Vec<PathBuf>,
+ ) {
+ if through_op <= self.checkpoint_requested.get() {
+ return;
+ }
+ self.checkpoint_requested.set(through_op);
+ self.checkpoint_needed.set(false);
+ let synced_files = self
+ .offset_files
+ .borrow()
+ .keys()
+ .map(PathBuf::from)
+ .collect();
+ let mut offset_files = std::mem::take(&mut *self.retired_offset_files.borrow_mut());
+ offset_files.extend(self.offset_files.borrow_mut().drain().map(|(_, file)| file));
+ self.queue.borrow_mut().push_back(Mutation::Checkpoint {
+ epoch: self.epoch.get(),
+ through_op,
+ files,
+ directories,
+ offset_files,
+ synced_files,
+ });
+ }
+
+ pub const fn checkpoint_pending(&self) -> bool {
+ self.checkpoint_running.get() || self.checkpoint_requested.get() > self.checkpoint.get()
+ }
+
+ pub fn request_checkpoint(&self) {
+ self.checkpoint_needed.set(true);
+ }
+
+ pub fn truncate_from(&self, from_op: u64) {
+ if from_op > self.accepted_head.get() {
+ return;
+ }
+ self.checkpoint_requested.set(self.checkpoint.get());
+ self.certified_log_view.set(None);
+ self.requested_log_view.set(None);
+ let epoch = self.epoch.get().wrapping_add(1);
+ self.epoch.set(epoch);
+ // Retained submissions still precede the replacement history.
+ self.queue
+ .borrow_mut()
+ .retain_mut(|mutation| match mutation {
+ Mutation::Append {
+ epoch: previous,
+ prepare,
+ bytes,
+ ..
+ } => {
+ if prepare_header(prepare).is_ok_and(|header| header.op < from_op) {
+ *previous = epoch;
+ true
+ } else {
+ self.queued_bytes
+ .set(self.queued_bytes.get().saturating_sub(*bytes));
+ false
+ }
+ }
+ Mutation::Checkpoint {
+ epoch: previous,
+ through_op,
+ ..
+ } if *through_op < from_op => {
+ *previous = epoch;
+ self.checkpoint_requested
+ .set(self.checkpoint_requested.get().max(*through_op));
+ true
+ }
+ _ => false,
+ });
+ self.accepted.borrow_mut().truncate_from(from_op);
+ self.segment_references.borrow_mut().split_off(&from_op);
+ self.accepted_head.set(from_op.saturating_sub(1));
+ self.written_head
+ .set(self.written_head.get().min(from_op.saturating_sub(1)));
+ self.durable_head
+ .set(self.durable_head.get().min(from_op.saturating_sub(1)));
+ self.queue
+ .borrow_mut()
+ .push_back(Mutation::Truncate { epoch, from_op });
+ }
+
+ pub fn reset(&self, op: u64, checksum: Option<u128>) {
+ self.reset_with_prepare(op, checksum, None);
+ }
+
+ pub fn reset_with_prepare(
+ &self,
+ op: u64,
+ checksum: Option<u128>,
+ prepare: Option<Frozen<4096>>,
+ ) {
+ self.reset_with_segments(op, checksum, prepare, None);
+ }
+
+ pub fn reset_with_segments(
+ &self,
+ op: u64,
+ checksum: Option<u128>,
+ prepare: Option<Frozen<4096>>,
+ segments: Option<(SegmentPosition, u64)>,
+ ) {
+ self.offset_files.borrow_mut().clear();
+ self.retired_offset_files.borrow_mut().clear();
+ self.certified_log_view.set(None);
+ self.requested_log_view.set(None);
+ self.checkpoint_requested.set(op);
+ let epoch = self.epoch.get().wrapping_add(1);
+ self.epoch.set(epoch);
+ self.queue.borrow_mut().clear();
+ self.queued_bytes.set(0);
+ *self.accepted.borrow_mut() = AcceptedPrepares {
+ base: op,
+ checksums: VecDeque::new(),
+ };
+ self.accepted_head.set(op);
+ self.written_head.set(0);
+ self.durable_head.set(0);
+ self.segment_references.borrow_mut().clear();
+ self.queue.borrow_mut().push_back(Mutation::Reset {
+ epoch,
+ op,
+ checksum,
+ prepare,
+ segments,
+ });
+ }
+
+ pub fn mark_purge(&self, generation: u64, floor: u64) {
+ self.queue.borrow_mut().push_back(Mutation::Purge {
+ epoch: self.epoch.get(),
+ generation,
+ floor,
+ });
+ }
+
+ pub const fn purge_marker(&self) -> (u64, u64) {
+ (self.purge_generation.get(), self.purge_floor.get())
+ }
+
+ pub fn needs_checkpoint(&self) -> bool {
+ !self.checkpoint_pending()
+ && (self.checkpoint_needed.get()
+ || self.retained_bytes.get() + self.queued_bytes.get() + self.in_flight_bytes.get()
+ >= self.capacity / 2
+ || self.retired_offset_files.borrow().len() >= CHECKPOINT_DIRTY_FILES_MAX
+ || self.dirty_segments.borrow().len() * 2
+ + self
+ .dirty_offsets
+ .iter()
+ .map(|offsets| offsets.borrow().len())
+ .sum::<usize>()
+ >= CHECKPOINT_DIRTY_FILES_MAX)
+ }
+
+ pub fn take_metrics(&self) -> PersistenceMetrics {
+ PersistenceMetrics {
+ disk_bytes: self.disk_bytes.get(),
+ retained_bytes: self.retained_bytes.get(),
+ queued_bytes: self.queued_bytes.get(),
+ in_flight_bytes: self.in_flight_bytes.get(),
+ checkpoints_pending: u64::from(self.checkpoint_pending()),
+ completed_batches: self.completed_batches.replace(0),
+ batched_prepares: self.batched_prepares.replace(0),
+ completed_checkpoints: self.completed_checkpoints.replace(0),
+ failed_writes: self.failed_writes.replace(0),
+ }
+ }
+
+ pub fn fail(&self, error: io::Error) {
+ self.fail_operation(error, Operation::SendMessages);
+ }
+
+ pub fn fail_operation(&self, error: io::Error, operation: Operation) {
+ self.failure_operation.set(operation);
+ self.failed_writes.set(self.failed_writes.get() + 1);
+ *self.failure.borrow_mut() = Some(Arc::new(error));
+ self.notify();
+ }
+
+ pub fn failure(&self) -> Option<Arc<io::Error>> {
+ self.failure.borrow().clone()
+ }
+
+ pub const fn failure_operation(&self) -> Operation {
+ self.failure_operation.get()
+ }
+
+ pub const fn checkpoint_op(&self) -> u64 {
+ self.checkpoint.get()
+ }
+
+ pub fn checksum(&self, op: u64) -> Option<u128> {
+ if op == self.checkpoint.get() {
+ self.checkpoint_checksum.get()
+ } else {
+ self.accepted.borrow().checksum(op)
+ }
+ }
+
+ pub const fn head(&self) -> u64 {
+ self.accepted_head.get()
+ }
+
+ pub fn retire(&self) {
+ self.retired.set(true);
+ if let Some(lease) = &self.lease {
+ lease.retired.store(true, Ordering::Release);
+ }
+ self.queue.borrow_mut().clear();
+ self.queued_bytes.set(0);
+ }
+
+ /// # Errors
+ /// Returns an error if persistence fails, capacity is exhausted, or history is invalid.
+ pub async fn drain(&self) -> io::Result<()> {
+ futures::future::poll_fn(|context| {
+ if !self.running.get()
+ && let Some(error) = self.failure()
+ {
+ return std::task::Poll::Ready(Err(io::Error::new(error.kind(), error)));
+ }
+ if !self.running.get() && self.queue.borrow().is_empty() {
+ return std::task::Poll::Ready(Ok(()));
+ }
+ let mut waiters = self.waiters.borrow_mut();
+ if !waiters
+ .iter()
+ .any(|waiter| waiter.will_wake(context.waker()))
+ {
+ waiters.push(context.waker().clone());
+ }
+ std::task::Poll::Pending
+ })
+ .await
+ }
+
+ /// # Errors
+ /// Returns a storage failure or a timeout without allowing a second writer.
+ pub async fn drain_with_timeout(&self) -> io::Result<()> {
+ compio::runtime::time::timeout(PERSISTENCE_DRAIN_TIMEOUT, self.drain())
+ .await
+ .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "partition WAL drain timed out"))?
+ }
+
+ pub fn start(&self) -> bool {
+ let start = !self.retired.get()
+ && self.failure.borrow().is_none()
+ && !self.queue.borrow().is_empty()
+ && !self.running.replace(true);
+ if start && let Some(lease) = &self.lease {
+ lease.running.store(true, Ordering::Release);
+ }
+ start
+ }
+
+ pub fn run(self: Rc<Self>) -> impl Future<Output = ()> {
+ let mut ticket = WriterTicket {
+ owner: self,
+ started: false,
+ };
+ async move {
+ ticket.started = true;
+ Rc::clone(&ticket.owner).run_inner().await;
+ }
+ }
+
+ async fn run_inner(self: Rc<Self>) {
+ if self.writer_active.replace(true) {
+ self.fail(io::Error::other("partition WAL writer was started twice"));
+ return;
+ }
+ self.running.set(true);
+ if let Some(lease) = &self.lease {
+ lease.running.store(true, Ordering::Release);
+ }
+ let journal = self.journal.borrow_mut().take();
+ let mut guard = WriterGuard {
+ owner: &self,
+ journal,
+ complete: false,
+ };
+ let Some(journal) = guard.journal.as_mut() else {
+ self.fail(io::Error::other("partition WAL writer has no journal"));
+ guard.complete = true;
+ return;
+ };
+ loop {
+ if self.retired.get() {
+ break;
+ }
+ let Some(mutation) = self.queue.borrow_mut().pop_front() else {
+ break;
+ };
+ let (epoch, bytes) = match &mutation {
+ Mutation::Append { epoch, bytes, .. } => (*epoch, *bytes),
+ Mutation::CertifyView { epoch, .. }
+ | Mutation::EnableSegments { epoch, .. }
+ | Mutation::Purge { epoch, .. }
+ | Mutation::Truncate { epoch, .. }
+ | Mutation::Checkpoint { epoch, .. }
+ | Mutation::Reset { epoch, .. } => (*epoch, 0),
+ };
+ self.queued_bytes
+ .set(self.queued_bytes.get().saturating_sub(bytes));
+ self.in_flight_bytes.set(bytes);
+ let rebuild_references = matches!(
+ mutation,
+ Mutation::EnableSegments { .. }
+ | Mutation::Purge { .. }
+ | Mutation::Truncate { .. }
+ | Mutation::Checkpoint { .. }
+ | Mutation::Reset { .. }
+ );
+ let result = self.apply_mutation(journal, mutation, epoch, bytes).await;
+ self.in_flight_bytes.set(0);
+ if let Err(error) = result {
+ self.failed_writes.set(self.failed_writes.get() + 1);
+ *self.failure.borrow_mut() = Some(Arc::new(error));
+ self.notify();
+ break;
+ }
+ if epoch == self.epoch.get() && !self.retired.get() {
+ let mut references = self.segment_references.borrow_mut();
+ if rebuild_references {
+ references.clear();
+ references.extend(journal.written_segment_references(0));
+ } else if let Some(from_op) = self.written_head.get().checked_add(1) {
+ references.extend(journal.written_segment_references(from_op));
+ }
+ drop(references);
+ self.disk_bytes.set(journal.size_bytes());
+ self.retained_bytes.set(journal.retained_bytes());
+ self.segment_checkpoint.set(journal.segment_checkpoint());
+ let advanced = journal.durable_op() != self.durable_head.get()
+ || journal.checkpoint_op() != self.checkpoint.get()
+ || journal.certified_log_view() != self.certified_log_view.get()
+ || (journal.segment_checkpoint().is_some()
+ && journal.head() != self.written_head.get());
+ self.certified_log_view.set(journal.certified_log_view());
+ if self
+ .requested_log_view
+ .get()
+ .is_some_and(|(view, _, _)| Some(view) == self.certified_log_view.get())
+ {
+ self.requested_log_view.set(None);
+ }
+ self.written_head.set(journal.head());
+ self.durable_head.set(journal.durable_op());
+ if journal.checkpoint_op() > self.checkpoint.get() {
+ self.accepted
+ .borrow_mut()
+ .checkpoint(journal.checkpoint_op());
+ }
+ self.checkpoint.set(journal.checkpoint_op());
+ self.checkpoint_checksum.set(journal.checkpoint_checksum());
+ self.purge_generation.set(journal.purge_marker().0);
+ self.purge_floor.set(journal.purge_marker().1);
+ if advanced {
+ self.notify();
+ }
+ }
+ }
+ guard.complete = true;
+ }
+
+ async fn apply_mutation(
+ &self,
+ journal: &mut PartitionPrepareJournal<S>,
+ mutation: Mutation<S>,
+ epoch: u64,
+ bytes: u64,
+ ) -> io::Result<()> {
+ match mutation {
+ Mutation::EnableSegments {
+ initial, max_size, ..
+ } => journal.enable_segment_storage(initial, max_size).await,
+ Mutation::CertifyView {
+ view, op, checksum, ..
+ } => journal.certify_log_view(view, op, checksum).await,
+ Mutation::Purge {
+ generation, floor, ..
+ } => journal.mark_purge(generation, floor).await,
+ Mutation::Append {
+ prepare, durable, ..
+ } => {
+ self.append_batch(journal, prepare, durable, epoch, bytes)
+ .await
+ }
+ Mutation::Truncate { from_op, .. } => journal.truncate_from(from_op).await,
+ Mutation::Checkpoint {
+ through_op,
+ files,
+ directories,
+ offset_files,
+ synced_files,
+ ..
+ } => {
+ self.checkpoint_running.set(true);
+ let result = async {
+ futures::stream::iter(offset_files.iter().map(Ok::<_, io::Error>))
+ .try_for_each_concurrent(16, |retained| retained.file.sync())
+ .await?;
+ journal
+ .checkpoint_files(through_op, &files, &directories, &synced_files)
+ .await
+ }
+ .await;
+ self.checkpoint_running.set(false);
+ if result.is_ok() {
+ self.completed_checkpoints
+ .set(self.completed_checkpoints.get() + 1);
+ }
+ result
+ }
+ Mutation::Reset {
+ op,
+ checksum,
+ prepare,
+ segments,
+ ..
+ } => {
+ if let Some((position, max_size)) = segments {
+ journal
+ .reset_with_segment_checkpoint(op, checksum, prepare, position, max_size)
+ .await
+ } else if let Some(prepare) = prepare {
+ journal.reset_with_prepare(prepare).await
+ } else {
+ journal.reset(op, checksum).await
+ }
+ }
+ }
+ }
+
+ async fn append_batch(
+ &self,
+ journal: &mut PartitionPrepareJournal<S>,
+ first: Frozen<4096>,
+ mut durable: bool,
+ epoch: u64,
+ first_bytes: u64,
+ ) -> io::Result<()> {
+ let mut batch = SmallVec::<[Frozen<4096>; 8]>::new();
+ batch.push(first);
+ let mut bytes = first_bytes;
+ {
+ let mut queue = self.queue.borrow_mut();
+ while batch.len() < APPEND_BATCH_OPS_MAX {
+ let Some(Mutation::Append {
+ epoch: next_epoch,
+ bytes: next_bytes,
+ ..
+ }) = queue.front()
+ else {
+ break;
+ };
+ if *next_epoch != epoch
+ || bytes.saturating_add(*next_bytes) > APPEND_BATCH_BYTES_MAX
+ {
+ break;
+ }
+ let Some(Mutation::Append {
+ prepare,
+ durable: requires_sync,
+ bytes: record_bytes,
+ ..
+ }) = queue.pop_front()
+ else {
+ unreachable!("append prefix was checked");
+ };
+ bytes += record_bytes;
+ self.queued_bytes
+ .set(self.queued_bytes.get().saturating_sub(record_bytes));
+ durable |= requires_sync;
+ batch.push(prepare);
+ }
+ }
+ self.in_flight_bytes.set(bytes);
+ let count = batch.len() as u64;
+ journal.append_batch_buffered(&batch).await?;
+ if durable {
+ journal.sync().await?;
+ self.completed_batches.set(self.completed_batches.get() + 1);
+ self.batched_prepares
+ .set(self.batched_prepares.get() + count);
+ }
+ Ok(())
+ }
+
+ fn notify(&self) {
+ if let Some(notifier) = self.notifier.borrow().as_ref() {
+ notifier(PersistenceCompletion {
+ group: self.group,
+ instance: self.instance,
+ epoch: self.epoch.get(),
+ });
+ }
+ }
+}
+
+fn prepare_header(prepare: &Frozen<4096>) -> io::Result<&PrepareHeader> {
+ let bytes = prepare
+ .as_slice()
+ .get(..size_of::<PrepareHeader>())
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "short prepare"))?;
+ bytemuck::checked::try_from_bytes(bytes)
+ .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid prepare"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use iggy_binary_protocol::{Command, Operation};
+ use server_common::{Message, iobuf::Owned};
+ use tempfile::tempdir;
+
+ #[compio::test]
+ async fn completion_is_generation_scoped_and_buffered_work_does_not_ack_durability() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let (persistence, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ let notifications = Rc::new(RefCell::new(Vec::new()));
+ let captured = Rc::clone(¬ifications);
+ persistence.set_notifier(Rc::new(move |completion| {
+ captured.borrow_mut().push(completion);
+ }));
+ let first = prepare(1, 0);
+ persistence
+ .append(first.clone().into_frozen(), false)
+ .unwrap();
+ assert!(!persistence.is_durable(first.header()));
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(notifications.borrow().is_empty());
+ let next = prepare(2, first.header().checksum);
+ persistence
+ .append(next.clone().into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.is_durable(first.header()));
+ assert!(persistence.is_durable(next.header()));
+ let completion = notifications.borrow()[0];
+ assert!(persistence.accepts_completion(completion));
+ persistence.truncate_from(2);
+ assert!(!persistence.accepts_completion(completion));
+ assert!(!persistence.is_durable(next.header()));
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ persistence.drain().await.unwrap();
+ assert!(persistence.is_durable(first.header()));
+ }
+
+ #[compio::test]
+ async fn truncating_beyond_the_head_does_not_create_an_operation_gap() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let (persistence, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ persistence.truncate_from(8);
+ assert_eq!(persistence.head(), 0);
+ let first = prepare(1, 0);
+ persistence.append(first.into_frozen(), true).unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert_eq!(persistence.head(), 1);
+ assert!(persistence.failure().is_none());
+ }
+
+ #[compio::test]
+ async fn checkpoint_tracks_only_dirty_files_and_preserves_committed_barriers_on_truncation() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let (persistence, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ persistence.mark_segment_dirty(0);
+ persistence.mark_segment_dirty(0);
+ persistence.mark_offset_dirty(0, 7, true);
+ persistence.mark_offset_dirty(0, 7, false);
+ persistence.mark_offset_dirty(1, 9, true);
+ let (segments, offsets) = persistence.take_dirty_files();
+ assert_eq!(segments.into_iter().collect::<Vec<_>>(), vec![0]);
+ assert!(offsets[0].is_empty());
+ assert!(offsets[1].contains(&9));
+ let (segments, offsets) = persistence.take_dirty_files();
+ assert!(segments.is_empty());
+ assert!(offsets.iter().all(BTreeSet::is_empty));
+ let first = prepare(1, 0);
+ let checkpoint_header = *first.header();
+ let second = prepare(2, first.header().checksum);
+ persistence.append(first.into_frozen(), true).unwrap();
+ persistence.append(second.into_frozen(), true).unwrap();
+ persistence.checkpoint(1);
+ persistence.truncate_from(2);
+ assert!(persistence.checkpoint_pending());
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ assert_eq!(persistence.checkpoint_op(), 1);
+ assert!(persistence.is_durable(&checkpoint_header));
+ assert!(!persistence.checkpoint_pending());
+ }
+
+ #[compio::test]
+ async fn dropping_an_unpolled_writer_releases_drain_waiters() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let (persistence, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ persistence
+ .append(prepare(1, 0).into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ drop(Rc::clone(&persistence).run());
+ assert!(!persistence.running.get());
+ assert!(persistence.journal.borrow().is_some());
+ assert_eq!(
+ persistence.drain().await.unwrap_err().kind(),
+ io::ErrorKind::Interrupted
+ );
+ }
+
+ #[compio::test]
+ async fn replacement_waits_for_the_retired_writer_and_rejects_a_live_owner() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let next_incarnation = directory.path().join("prepares-8");
+ let (old, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ assert!(
+ PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .is_err()
+ );
+ assert!(
+ PartitionPersistence::open(&next_incarnation, 42, 8)
+ .await
+ .is_err()
+ );
+ old.append(prepare(1, 0).into_frozen(), true).unwrap();
+ assert!(old.start());
+ old.retire();
+ let mut replacement = Box::pin(PartitionPersistence::open(&next_incarnation, 42, 8));
+ assert!(futures::poll!(&mut replacement).is_pending());
+ Rc::clone(&old).run().await;
+ let (replacement, _) = replacement.await.unwrap();
+ assert_eq!(replacement.head(), 0);
+ assert!(replacement.failure().is_none());
+ }
+
+ #[compio::test]
+ async fn replacement_waiting_on_a_cancelled_writer_preserves_the_restart_fence() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let next_incarnation = directory.path().join("prepares-8");
+ let (old, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ old.append(prepare(1, 0).into_frozen(), true).unwrap();
+ assert!(old.start());
+ let mut writer = Box::pin(Rc::clone(&old).run());
+ assert!(futures::poll!(&mut writer).is_pending());
+ old.retire();
+ let mut replacement = Box::pin(PartitionPersistence::open(&next_incarnation, 42, 8));
+ assert!(futures::poll!(&mut replacement).is_pending());
+
+ drop(writer);
+
+ assert_eq!(old.failure().unwrap().kind(), io::ErrorKind::Interrupted);
+ assert!(
+ replacement.await.is_err(),
+ "cancellation must fence an already-waiting replacement"
+ );
+ assert!(
+ PartitionPersistence::open(&next_incarnation, 42, 8)
+ .await
+ .is_err()
+ );
+ }
+
+ #[compio::test]
+ async fn dirty_file_count_requests_a_checkpoint_below_the_byte_threshold() {
+ let directory = tempdir().unwrap();
+ let wal_directory = directory.path().join("prepares-7");
+ let (persistence, _) = PartitionPersistence::open(&wal_directory, 42, 7)
+ .await
+ .unwrap();
+ for consumer_id in 0..u32::try_from(CHECKPOINT_DIRTY_FILES_MAX).unwrap() {
+ persistence.mark_offset_dirty(0, consumer_id, true);
+ }
+ assert!(persistence.needs_checkpoint());
+ assert_eq!(persistence.disk_bytes.get(), 0);
+ }
+
+ fn prepare(op: u64, parent: u128) -> Message<PrepareHeader> {
+ let mut owned = Owned::<4096>::zeroed(size_of::<PrepareHeader>());
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(owned.as_mut_slice());
+ header.command = Command::Prepare;
+ header.operation = Operation::StoreConsumerOffset;
+ header.group = 42;
+ header.op = op;
+ header.parent = parent;
+ header.size = u32::try_from(size_of::<PrepareHeader>()).unwrap();
+ header.checksum = header.identity_checksum();
+ Message::try_from(owned).unwrap()
+ }
+}
diff --git a/core/partitions/src/state_transfer.rs b/core/partitions/src/state_transfer.rs
index 94a62b6..1a81ff5 100644
--- a/core/partitions/src/state_transfer.rs
+++ b/core/partitions/src/state_transfer.rs
@@ -43,10 +43,14 @@
use consensus::{
ArtifactProgress, DedupWatermark, Sequencer as _, StateArtifactHasher, state_artifact_checksum,
};
+use iggy_binary_protocol::{Operation, PrepareHeader};
use iggy_common::{ConsumerGroupId, ConsumerKind, ConsumerOffset, IggyByteSize};
+use journal::durable_storage::{DiskStorage, DurableStorage};
use journal::superblock::SuperblockStore;
use message_bus::MessageBus;
-use server_common::send_messages::decode_batch_slice;
+use server_common::Message;
+use server_common::iobuf::Owned;
+use server_common::send_messages::{decode_batch_slice, decode_prepare_slice};
use server_common::{SegmentStorage, yield_to_reactor};
use std::collections::{HashMap, HashSet};
use std::fmt;
@@ -56,26 +60,9 @@
use std::rc::Rc;
use std::sync::atomic::Ordering;
-/// Framing marker for the consumer-offsets wire artifact, "ICO2". Bumped with
-/// the version when the dedup section was appended, so the magic alone tells
-/// the two layouts apart.
-pub(crate) const CONSUMER_OFFSETS_MAGIC: [u8; 4] = *b"ICO2";
-
-/// Version byte following the magic.
-///
-/// Any layout change bumps this, INCLUDING appended fields: the decoder
-/// deliberately fails closed on unknown versions and on trailing bytes,
-/// because a v2 field can change the meaning of fields v1 already read.
-pub(crate) const CONSUMER_OFFSETS_VERSION: u8 = 2;
-
-/// The previous framing, "ICO1" at version 1: the same layout without the
-/// dedup section. Still decoded so a rolling upgrade works in both orders --
-/// an upgraded replica rejoining behind the repair floor of an un-upgraded
-/// primary installs its artifact with an empty slice (dedup for that window
-/// degrades to at-least-once, exactly the pre-dedup behaviour) instead of
-/// refusing it and re-pulling forever.
-pub(crate) const CONSUMER_OFFSETS_MAGIC_V1: [u8; 4] = *b"ICO1";
-const CONSUMER_OFFSETS_VERSION_V1: u8 = 1;
+/// Current state-transfer offsets format, including the prepare-chain anchor.
+pub(crate) const CONSUMER_OFFSETS_MAGIC: [u8; 4] = *b"ICO1";
+pub(crate) const CONSUMER_OFFSETS_VERSION: u8 = 1;
/// Per-section entry ceiling for the consumer-offsets artifact.
///
@@ -305,6 +292,9 @@
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ConsumerOffsetsWire {
pub purge_generation: u64,
+ /// Checksum of the prepare at the offer's committed operation.
+ pub prepare_checksum: Option<u128>,
+ pub checkpoint_prepare: Vec<u8>,
/// The origin group's message-offset frontier: the offset the NEXT
/// append will mint, `0` for a partition that never appended. Segments
/// alone cannot carry this -- retention can GC every sealed segment
@@ -327,8 +317,8 @@
/// consumer_count u32 | group_count u32 | dedup_count u32 |
/// {id u32, offset u64}xN | {id u32, offset u64}xM |
/// {client u128, watermark u64, latest_commit u64, user_id u32,
- /// committed_window u128}xD |
- /// XxHash3_64 trailer`. Little-endian throughout.
+ /// committed_window u128}xD | checksum_present u8 | prepare_checksum u128 |
+ /// prepare_length u32 | checkpoint_prepare bytes | XxHash3_64 trailer`. Little-endian throughout.
#[must_use]
pub fn encode(&self) -> Vec<u8> {
// Size exactly rather than guess; the reservation assert keeps the
@@ -339,6 +329,10 @@
+ 3 * size_of::<u32>()
+ (self.consumers.len() + self.groups.len()) * (size_of::<u32>() + size_of::<u64>())
+ self.dedup.len() * DEDUP_ENTRY_LEN
+ + size_of::<u8>()
+ + size_of::<u128>()
+ + size_of::<u32>()
+ + self.checkpoint_prepare.len()
+ size_of::<u64>();
let mut out = Vec::with_capacity(reserved);
out.extend_from_slice(&CONSUMER_OFFSETS_MAGIC);
@@ -362,6 +356,14 @@
out.extend_from_slice(&entry.user_id.to_le_bytes());
out.extend_from_slice(&entry.committed_window.to_le_bytes());
}
+ out.push(u8::from(self.prepare_checksum.is_some()));
+ out.extend_from_slice(&self.prepare_checksum.unwrap_or(0).to_le_bytes());
+ out.extend_from_slice(
+ &u32::try_from(self.checkpoint_prepare.len())
+ .expect("bounded checkpoint prepare")
+ .to_le_bytes(),
+ );
+ out.extend_from_slice(&self.checkpoint_prepare);
debug_assert_eq!(out.len() + size_of::<u64>(), reserved, "encode reservation");
let trailer = state_artifact_checksum(&out);
out.extend_from_slice(&trailer.to_le_bytes());
@@ -389,31 +391,32 @@
let mut cursor = LeCursor::new(content);
let magic = cursor.take(CONSUMER_OFFSETS_MAGIC.len())?;
let version = cursor.u8()?;
- let carries_dedup = if magic == CONSUMER_OFFSETS_MAGIC {
- if version != CONSUMER_OFFSETS_VERSION {
- return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
- }
- true
- } else if magic == CONSUMER_OFFSETS_MAGIC_V1 {
- if version != CONSUMER_OFFSETS_VERSION_V1 {
- return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
- }
- false
- } else {
+ if magic != CONSUMER_OFFSETS_MAGIC {
return Err(ConsumerOffsetsWireError::BadMagic);
- };
+ }
+ if version != CONSUMER_OFFSETS_VERSION {
+ return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
+ }
let purge_generation = cursor.u64()?;
let next_offset = cursor.u64()?;
let consumer_count = cursor.u32()?;
let group_count = cursor.u32()?;
- let dedup_count = if carries_dedup { cursor.u32()? } else { 0 };
+ let dedup_count = cursor.u32()?;
let consumers = Self::decode_section(&mut cursor, "consumers", consumer_count)?;
let groups = Self::decode_section(&mut cursor, "groups", group_count)?;
- let dedup = if carries_dedup {
- Self::decode_dedup_section(&mut cursor, dedup_count)?
- } else {
- Vec::new()
+ let dedup = Self::decode_dedup_section(&mut cursor, dedup_count)?;
+ let present = cursor.u8()?;
+ let checksum = cursor.u128()?;
+ let prepare_checksum = match present {
+ 0 if checksum == 0 => None,
+ 1 => Some(checksum),
+ _ => return Err(ConsumerOffsetsWireError::InvalidPrepareChecksum),
};
+ let prepare_length = cursor.u32()? as usize;
+ if prepare_length > journal::partition_journal::PREPARE_BYTES_MAX {
+ return Err(ConsumerOffsetsWireError::InvalidPrepareChecksum);
+ }
+ let checkpoint_prepare = cursor.take(prepare_length)?.to_vec();
if !cursor.remaining().is_empty() {
// Distinct from `Truncated`: extra bytes point at a NEWER
// encoder, and telling the operator the artifact is short would
@@ -424,6 +427,8 @@
}
Ok(Self {
purge_generation,
+ prepare_checksum,
+ checkpoint_prepare,
next_offset,
consumers,
groups,
@@ -525,6 +530,8 @@
/// different trust (this node's own bytes vs a peer's).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsumerOffsetsWireError {
+ InvalidPrepareChecksum,
+ MissingPrepareChecksum,
Truncated,
BadMagic,
UnsupportedVersion {
@@ -569,8 +576,16 @@
impl fmt::Display for ConsumerOffsetsWireError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
+ Self::InvalidPrepareChecksum => write!(f, "invalid state-transfer prepare checksum"),
+ Self::MissingPrepareChecksum => {
+ write!(f, "durable state transfer requires a prepare checksum")
+ }
Self::Truncated => write!(f, "consumer-offsets artifact is truncated"),
- Self::BadMagic => write!(f, "consumer-offsets artifact carries a foreign magic"),
+ Self::BadMagic => write!(
+ f,
+ "consumer-offsets artifact must use {} version {CONSUMER_OFFSETS_VERSION}",
+ String::from_utf8_lossy(&CONSUMER_OFFSETS_MAGIC)
+ ),
Self::TrailingBytes { extra } => write!(
f,
"consumer-offsets artifact carries {extra} trailing bytes past this \
@@ -657,6 +672,8 @@
fn table() -> ConsumerOffsetsWire {
ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 3,
next_offset: 43,
consumers: vec![(1, 10), (7, 42)],
@@ -688,8 +705,30 @@
}
#[test]
+ fn transferred_prepare_checksum_is_covered_by_the_artifact() {
+ let mut table = table();
+ table.prepare_checksum = Some(u128::MAX - 7);
+ let bytes = table.encode();
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&bytes)
+ .unwrap()
+ .prepare_checksum,
+ table.prepare_checksum
+ );
+ let mut corrupt = bytes;
+ let position = corrupt.len() - 9;
+ corrupt[position] ^= 1;
+ assert!(matches!(
+ ConsumerOffsetsWire::decode(&corrupt),
+ Err(ConsumerOffsetsWireError::ChecksumMismatch { .. })
+ ));
+ }
+
+ #[test]
fn given_empty_table_when_encoded_should_round_trip() {
let empty = ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
@@ -757,6 +796,8 @@
#[test]
fn given_unordered_dedup_clients_when_decoded_should_reject() {
let unordered = ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
@@ -772,6 +813,8 @@
#[test]
fn given_reserved_client_in_dedup_when_decoded_should_reject() {
let reserved = ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
@@ -785,51 +828,18 @@
}
#[test]
- fn given_v1_artifact_when_decoded_should_install_empty_dedup() {
- // An un-upgraded primary still ships "ICO1": same fields minus the
- // dedup count and section. It must decode, with nothing to absorb.
- let mut bytes = Vec::new();
- bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC_V1);
- bytes.push(CONSUMER_OFFSETS_VERSION_V1);
- bytes.extend_from_slice(&3u64.to_le_bytes());
- bytes.extend_from_slice(&43u64.to_le_bytes());
- bytes.extend_from_slice(&1u32.to_le_bytes());
- bytes.extend_from_slice(&1u32.to_le_bytes());
- for (id, offset) in [(7u32, 42u64), (2, 5)] {
- bytes.extend_from_slice(&id.to_le_bytes());
- bytes.extend_from_slice(&offset.to_le_bytes());
+ fn given_unknown_artifact_formats_when_decoded_should_reject() {
+ for magic in [b"BAD1", b"ICO9"] {
+ let mut bytes = table().encode();
+ bytes[..4].copy_from_slice(magic);
+ let length = bytes.len() - 8;
+ let checksum = state_artifact_checksum(&bytes[..length]);
+ bytes[length..].copy_from_slice(&checksum.to_le_bytes());
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&bytes),
+ Err(ConsumerOffsetsWireError::BadMagic)
+ );
}
- let trailer = state_artifact_checksum(&bytes);
- bytes.extend_from_slice(&trailer.to_le_bytes());
- assert_eq!(
- ConsumerOffsetsWire::decode(&bytes),
- Ok(ConsumerOffsetsWire {
- purge_generation: 3,
- next_offset: 43,
- consumers: vec![(7, 42)],
- groups: vec![(2, 5)],
- dedup: Vec::new(),
- })
- );
- }
-
- #[test]
- fn given_v1_magic_with_wrong_version_when_decoded_should_reject() {
- let mut bytes = Vec::new();
- bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC_V1);
- bytes.push(CONSUMER_OFFSETS_VERSION);
- bytes.extend_from_slice(&0u64.to_le_bytes());
- bytes.extend_from_slice(&0u64.to_le_bytes());
- bytes.extend_from_slice(&0u32.to_le_bytes());
- bytes.extend_from_slice(&0u32.to_le_bytes());
- let trailer = state_artifact_checksum(&bytes);
- bytes.extend_from_slice(&trailer.to_le_bytes());
- assert_eq!(
- ConsumerOffsetsWire::decode(&bytes),
- Err(ConsumerOffsetsWireError::UnsupportedVersion {
- version: CONSUMER_OFFSETS_VERSION
- })
- );
}
#[test]
@@ -899,6 +909,8 @@
#[test]
fn given_duplicate_or_unordered_ids_when_decoded_should_reject() {
let duplicate = ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 0,
consumers: vec![(5, 1), (5, 2)],
@@ -913,6 +925,8 @@
})
);
let unordered = ConsumerOffsetsWire {
+ prepare_checksum: None,
+ checkpoint_prepare: Vec::new(),
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
@@ -1167,15 +1181,16 @@
}
/// A built partition state-transfer offer: everything at `commit_op`, with
-/// segment payloads addressed by path and only the (small) offsets artifact
-/// resident.
+/// segment payloads addressed by path and the offsets artifact resident.
+///
+/// The offsets artifact can include a full checkpoint prepare.
#[derive(Debug)]
pub struct PartitionStateTransferOffer {
/// `== commit_min == commit_max` at build (caught-up primary gate).
pub commit_op: u64,
/// Ascending base offset; one artifact per non-empty retained segment.
pub segments: Vec<SegmentArtifactSource>,
- /// The consumer-offsets artifact, resident (a few KB at most).
+ /// Resident consumer offsets, dedup state, and an optional checkpoint prepare.
pub offsets: (consensus::StateArtifact, std::rc::Rc<Vec<u8>>),
}
@@ -1229,6 +1244,9 @@
/// In-memory / simulated partition: nothing on disk to serve.
NoPartitionDir,
RepairInProgress,
+ MissingPrepareChecksum {
+ op: u64,
+ },
ConsumerOffsetsTooLarge {
kind: ConsumerKind,
count: usize,
@@ -1287,6 +1305,7 @@
| Self::SegmentSetChanged
| Self::OfferBuildInProgress { .. } => true,
Self::NoPartitionDir
+ | Self::MissingPrepareChecksum { .. }
| Self::ConsumerOffsetsTooLarge { .. }
| Self::ConsumerOffsetStateInconsistent { .. }
| Self::ManifestTooLarge { .. }
@@ -1300,6 +1319,9 @@
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotCaughtUpPrimary => write!(f, "not the caught-up primary of this group"),
+ Self::MissingPrepareChecksum { op } => {
+ write!(f, "partition has no checksum for committed op {op}")
+ }
Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
Self::RepairInProgress => write!(f, "partition is itself mid-repair"),
Self::ConsumerOffsetsTooLarge { kind, count, max } => write!(
@@ -1532,9 +1554,86 @@
.collect())
}
+/// Remove physical tails outside the logical segment list after draining the WAL.
+/// The caller holds the write lock and has published a purge or install backup.
+pub(crate) async fn remove_public_segment_files(partition_dir: &str) -> std::io::Result<()> {
+ let directory = Path::new(partition_dir);
+ for entry in DiskStorage.entries(directory).await? {
+ let name = Path::new(&entry.name);
+ if !entry.directory
+ && name
+ .extension()
+ .is_some_and(|extension| extension == "log" || extension == "index")
+ && name
+ .file_stem()
+ .and_then(|stem| stem.to_str())
+ .is_some_and(|stem| stem.parse::<u64>().is_ok())
+ {
+ match DiskStorage.remove_file(&directory.join(&entry.name)).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+ }
+ }
+ Ok(())
+}
+
+const MATERIALIZATION_MISSING: &str = "materialization.missing";
+
+/// Fence replacement files before quarantining the authoritative materialization.
+///
+/// # Errors
+/// Returns an error if the recovery fence cannot be published durably.
+pub async fn mark_materialization_missing(directory: &str, revision: u64) -> std::io::Result<()> {
+ let path = Path::new(directory).join(MATERIALIZATION_MISSING);
+ let temporary = Path::new(directory).join("materialization.missing.tmp");
+ let mut file = compio::fs::OpenOptions::new()
+ .create(true)
+ .truncate(true)
+ .write(true)
+ .open(&temporary)
+ .await?;
+ file.write_all_at(revision.to_le_bytes().to_vec(), 0)
+ .await
+ .0?;
+ file.sync_all().await?;
+ compio::fs::rename(temporary, path).await?;
+ fsync_dir(directory).await
+}
+
+/// # Errors
+/// Returns an error if the recovery fence cannot be read or validated.
+pub async fn materialization_is_missing(directory: &str, revision: u64) -> std::io::Result<bool> {
+ match compio::fs::read(Path::new(directory).join(MATERIALIZATION_MISSING)).await {
+ Ok(bytes) => {
+ let bytes: [u8; 8] = bytes.try_into().map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "invalid materialization fence",
+ )
+ })?;
+ Ok(u64::from_le_bytes(bytes) == revision)
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(error),
+ }
+}
+
+async fn clear_materialization_missing(directory: &str) -> std::io::Result<()> {
+ match compio::fs::remove_file(Path::new(directory).join(MATERIALIZATION_MISSING)).await {
+ Ok(()) => fsync_dir(directory).await,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ }
+}
+
/// Move every segment file in `partition_dir` aside into `<dir>.fenced.<n>/`,
/// returning the directory used.
///
+/// Boot recovery also supplies `wal_revision` to
+/// move the refused prepare WAL. Live callers leave it unset to retain open writers.
+///
/// The partition directory itself STAYS, and so do its two superblock slots:
/// they hold the group's only durable `(view, log_view)`, and moving them would
/// make the rebuild read an empty directory -- no `restore_partition_view`,
@@ -1553,7 +1652,10 @@
/// the rebuild plants segment 0 with `file_exists = false` and truncates
/// whatever the failed quarantine left, so callers tombstone the partition and
/// leave the bytes for an operator.
-pub async fn quarantine_segment_files(partition_dir: &str) -> std::io::Result<String> {
+pub async fn quarantine_partition_files(
+ partition_dir: &str,
+ wal_revision: Option<u64>,
+) -> std::io::Result<String> {
// `create_dir`, not stat-then-create: one syscall per attempt instead of
// two, and race-free. Deliberately NOT `create_dir_all`, which succeeds on
// an existing directory and would silently merge this fence into an earlier
@@ -1591,6 +1693,19 @@
};
compio::fs::rename(&path, &PathBuf::from(&target).join(name)).await?;
}
+ if let Some(revision) = wal_revision {
+ let name = format!("prepares-{revision}");
+ match compio::fs::rename(
+ &Path::new(partition_dir).join(&name),
+ &Path::new(&target).join(&name),
+ )
+ .await
+ {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+ }
// All three touched directories: the target (its new dirents), the source
// (the removals), and the source's parent (the target directory itself is a
// new dirent there). Without the target-side syncs a crash can leave the
@@ -1777,7 +1892,8 @@
/// state the artifacts represent. Segment bytes are NOT loaded here: the
/// offer records `(entry, path)` and the serving side loads one artifact
/// at a time, so building costs one streaming checksum pass per segment
- /// and the resident footprint is just the offsets table.
+ /// and the resident footprint is the offsets artifact, including the
+ /// checkpoint prepare.
///
/// # Errors
/// [`PartitionTransferUnavailable`]; the requester falls back to journal
@@ -2090,19 +2206,26 @@
Ok(checksum)
}
- /// [`quarantine_segment_files`] over this partition's directory, for the
+ /// [`quarantine_partition_files`] over this partition's directory, for the
/// shard's `ConvergeFailed` fence -- the safety argument (segment files
/// move, superblock slots STAY, copies are unreclaimed operator evidence)
/// lives on the free function. `None` for an in-memory partition.
///
/// # Errors
- /// The underlying `std::io::Error`; see [`quarantine_segment_files`] for why
+ /// The underlying `std::io::Error`; see [`quarantine_partition_files`] for why
/// a failure is not something the rebuild can absorb.
pub async fn quarantine_partition_dir(&self) -> std::io::Result<Option<String>> {
let Some(dir) = self.partition_dir.clone() else {
return Ok(None);
};
- quarantine_segment_files(&dir).await.map(Some)
+ if let Some(persistence) = &self.persistence {
+ persistence.retire();
+ persistence.drain_with_timeout().await?;
+ }
+ if self.consensus().replica_count() > 1 {
+ mark_materialization_missing(&dir, self.created_revision).await?;
+ }
+ quarantine_partition_files(&dir, None).await.map(Some)
}
/// Release the cached offer once no requester holds one (the shard's
@@ -2152,7 +2275,39 @@
// must resume minting at N either way.
let next_offset = self.offset_frontier();
let dedup = self.dedup().watermarks_sorted();
+ let commit_op = self.consensus().commit_min();
+ let prepare_checksum = if commit_op == 0 {
+ Some(0)
+ } else {
+ self.log
+ .journal()
+ .inner
+ .repair_header(commit_op)
+ .map(|header| header.checksum)
+ .or_else(|| {
+ self.persistence
+ .as_ref()
+ .and_then(|persistence| persistence.checksum(commit_op))
+ })
+ .or_else(|| {
+ (self.consensus().sequencer().current_sequence() == commit_op)
+ .then(|| self.consensus().last_prepare_checksum())
+ })
+ };
+ let checkpoint_prepare = self
+ .log
+ .journal()
+ .inner
+ .repair_entry(commit_op)
+ .map_or_else(Vec::new, |prepare| prepare.as_slice().to_vec());
+ if self.persistence.is_some()
+ && (prepare_checksum.is_none() || (commit_op > 0 && checkpoint_prepare.is_empty()))
+ {
+ return Err(PartitionTransferUnavailable::MissingPrepareChecksum { op: commit_op });
+ }
Ok(ConsumerOffsetsWire {
+ checkpoint_prepare,
+ prepare_checksum,
purge_generation: self.applied_purge_generation,
next_offset,
consumers,
@@ -2440,6 +2595,35 @@
});
}
let offsets_wire = ConsumerOffsetsWire::decode(offsets_bytes)?;
+ if self.persistence.is_some()
+ && (offsets_wire.prepare_checksum.is_none()
+ || (commit_op > 0 && offsets_wire.checkpoint_prepare.is_empty()))
+ {
+ return Err(ConsumerOffsetsWireError::MissingPrepareChecksum.into());
+ }
+ if !offsets_wire.checkpoint_prepare.is_empty() {
+ let prepare = Message::<PrepareHeader>::try_from(Owned::copy_from_slice(
+ &offsets_wire.checkpoint_prepare,
+ ))
+ .map_err(|_| ConsumerOffsetsWireError::InvalidPrepareChecksum)?;
+ let header = prepare.header();
+ if header.op != commit_op
+ || header.group != self.consensus().group()
+ || Some(header.checksum) != offsets_wire.prepare_checksum
+ || header.size as usize != offsets_wire.checkpoint_prepare.len()
+ || (header.checksum != 0 && header.identity_checksum() != header.checksum)
+ || (header.checksum_body != 0
+ && header.checksum_body
+ != u128::from(iggy_common::calculate_checksum(
+ &offsets_wire.checkpoint_prepare[size_of::<PrepareHeader>()..],
+ )))
+ || (header.operation == Operation::SendMessages
+ && header.checksum_body == 0
+ && decode_prepare_slice(prepare.as_slice()).is_err())
+ {
+ return Err(ConsumerOffsetsWireError::InvalidPrepareChecksum.into());
+ }
+ }
// Anti-rewind against the LOCAL OFFSET COUNTER, not the commit
// frontier: the partition journal is memory-only and
// `restore_partition_view` restores view/log_view alone, so `commit_min`
@@ -2514,6 +2698,27 @@
// this point, so a write fault leaves the old partition serviceable.
stage_offset_writes(&planned_offsets).await?;
+ let write_lock = self.write_lock.clone();
+ let _guard = write_lock.lock().await;
+ if let Some(persistence) = &self.persistence {
+ self.start_persistence();
+ persistence.drain_with_timeout().await.map_err(|source| {
+ PartitionInstallError::SwapIo {
+ path: partition_dir.clone(),
+ source,
+ }
+ })?;
+ if let Err(source) = crate::install_backup::begin(Path::new(&partition_dir)).await {
+ // The backup rename may have landed before its directory barrier failed.
+ // Further commits could then be erased by rollback on the next boot.
+ self.fence_install_failure(commit_op);
+ return Err(PartitionInstallError::SwapIo {
+ path: partition_dir.clone(),
+ source,
+ });
+ }
+ }
+
// ---- mutate phase ----
// Record the INCOMING frontier before anything destructive: the swap
// below unlinks the old chain and makes that durable before the first
@@ -2550,6 +2755,9 @@
.await
};
if !frontier_durable {
+ if self.persistence.is_some() {
+ self.fence_install_failure(commit_op);
+ }
discard_offset_writes(&planned_offsets).await;
return Err(PartitionInstallError::FrontierNotDurable {
frontier: offsets_wire.next_offset,
@@ -2559,8 +2767,6 @@
// the segment vectors drained, and a concurrent replicated append
// indexing `segments().len() - 1` on the emptied vec is exactly the
// race every other segment-vec mutator takes this lock against.
- let write_lock = self.write_lock.clone();
- let _guard = write_lock.lock().await;
// Captured before `staged` moves: the convergence may only claim an
// offset frontier when the offer itself proved nothing is retained
// below it.
@@ -2577,6 +2783,11 @@
)
.await;
if outcome.is_err() {
+ if self.persistence.is_some() {
+ // Keep the rollback snapshot intact until boot reopens every file.
+ self.fence_install_failure(commit_op);
+ return outcome;
+ }
discard_offset_writes(&planned_offsets).await;
// A mutate-phase failure can leave the log drained or half
// rebuilt while the disk already holds any prefix of the new
@@ -2619,6 +2830,24 @@
the durable record stays at the pre-swap claim until the next view change"
);
}
+ if self.persistence.is_some()
+ && let Err(source) = crate::install_backup::finish(Path::new(&partition_dir)).await
+ {
+ self.fence_install_failure(commit_op);
+ return Err(PartitionInstallError::SwapIo {
+ path: partition_dir,
+ source,
+ });
+ }
+ if outcome.is_ok() && self.materialization_missing {
+ clear_materialization_missing(&partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.clone(),
+ source,
+ })?;
+ self.materialization_missing = false;
+ }
outcome
}
@@ -2637,6 +2866,9 @@
partition_dir: &str,
next_offset: u64,
) -> Result<PartitionInstallOutcome, PartitionInstallError> {
+ if let Some(persistence) = &self.persistence {
+ persistence.retire_offset_files();
+ }
// Sweep staging strays a dead earlier attempt left behind, keeping
// only what THIS install is about to rename. Bounded disk hygiene;
// the reuse-scan sweeps too, and boot sweeps ALL of `.staging`
@@ -2701,6 +2933,18 @@
}
}
}
+ if self
+ .persistence
+ .as_ref()
+ .is_some_and(|persistence| persistence.segment_checkpoint().is_some())
+ {
+ remove_public_segment_files(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ }
fsync_dir(partition_dir)
.await
.map_err(|source| PartitionInstallError::SwapIo {
@@ -2785,8 +3029,21 @@
// sweep itself is right (a chain the live state does not know
// about would resurrect at boot), so one retry against a
// transient open failure is the only cheap save available.
- let open =
- || SegmentStorage::new(&log_final, &index_final, meta.size, meta.index_size, true);
+ let open = || async {
+ if self.persistence.is_some() {
+ SegmentStorage::with_read_only_messages(
+ &log_final,
+ &index_final,
+ meta.index_size,
+ true,
+ None,
+ )
+ .await
+ } else {
+ SegmentStorage::new(&log_final, &index_final, meta.size, meta.index_size, true)
+ .await
+ }
+ };
let storage = match open().await {
Ok(storage) => storage,
Err(_) => open()
@@ -2796,7 +3053,7 @@
source,
})?,
};
- let mut segment = Segment::new(meta.start_offset, self.effective_segment_size(config));
+ let mut segment = Segment::new(meta.start_offset, self.effective_segment_size());
segment.sealed = true;
segment.start_timestamp = meta.start_timestamp;
segment.end_timestamp = meta.end_timestamp;
@@ -2831,21 +3088,19 @@
source,
})?;
} else {
- let enforce_fsync = self.effective_enforce_fsync(config);
- let segment_size = self.effective_segment_size(config);
+ let persisted = self.durability().is_persisted();
+ let segment_size = self.effective_segment_size();
let preallocate_segments = self.effective_preallocate_segments(config);
let last = self.log.segments().len() - 1;
let storage = self.log.storages()[last].clone();
- if let (Some(messages_reader), Some(index_reader), Some(messages_w), Some(index_w)) = (
+ if let (Some(messages_reader), Some(messages_w)) = (
storage.messages_reader.as_ref(),
- storage.index_reader.as_ref(),
storage.messages_writer.as_ref(),
- storage.index_writer.as_ref(),
) {
let messages_writer = MessagesWriter::new(
&messages_reader.path(),
messages_w.size_counter(),
- enforce_fsync,
+ persisted,
true,
preallocate_segments.then_some(segment_size),
)
@@ -2854,10 +3109,15 @@
path: messages_reader.path(),
source,
})?;
+ self.log.messages_writers_mut()[last] = Some(Rc::new(messages_writer));
+ }
+ if let (Some(index_reader), Some(index_w)) =
+ (storage.index_reader.as_ref(), storage.index_writer.as_ref())
+ {
let index_writer = IggyIndexWriter::new(
&index_reader.path(),
index_w.size_counter(),
- enforce_fsync,
+ persisted,
true,
)
.await
@@ -2865,7 +3125,6 @@
path: index_reader.path(),
source,
})?;
- self.log.messages_writers_mut()[last] = Some(Rc::new(messages_writer));
self.log.index_writers_mut()[last] = Some(Rc::new(index_writer));
}
self.log.segments_mut()[last].sealed = false;
@@ -3143,6 +3402,54 @@
// before the swap.
self.purge_deferred = false;
+ if let Some(persistence) = &self.persistence {
+ let prepare = (!offsets_wire.checkpoint_prepare.is_empty())
+ .then(|| Owned::<4096>::copy_from_slice(&offsets_wire.checkpoint_prepare).into());
+ let segments = Some({
+ let segment = self.log.active_segment();
+ (
+ journal::partition_journal::SegmentPosition {
+ start_offset: segment.start_offset,
+ length: segment.size.as_bytes_u64(),
+ next_offset,
+ },
+ segment.max_size.as_bytes_u64(),
+ )
+ });
+ persistence.reset_with_segments(
+ commit_op,
+ offsets_wire.prepare_checksum,
+ prepare,
+ segments,
+ );
+ self.start_persistence();
+ persistence.drain_with_timeout().await.map_err(|source| {
+ PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ }
+ })?;
+ }
+ if let Some(persistence) = &self.persistence {
+ persistence.certify_log_view(
+ self.consensus().log_view(),
+ commit_op,
+ offsets_wire.prepare_checksum.unwrap_or(0),
+ );
+ self.start_persistence();
+ persistence.drain_with_timeout().await.map_err(|source| {
+ PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ }
+ })?;
+ }
+ if !offsets_wire.checkpoint_prepare.is_empty() {
+ self.log.journal().inner.restore_checkpoint_prepare(
+ commit_op,
+ Owned::<4096>::copy_from_slice(&offsets_wire.checkpoint_prepare).into(),
+ );
+ }
let consensus = self.consensus();
if commit_op > consensus.commit_min() {
consensus.set_commit_floor(commit_op);
@@ -3158,9 +3465,12 @@
// its entries are backed by the same erased journal, and
// `LocalPipeline::push` asserts op sequentiality in release, so a bare
// rewind would turn the silent desync into a shard panic on a replica
- // promoted mid-transfer. (`last_prepare_checksum` needs nothing: it is
- // only read as a `parent:` stamp when building a prepare.)
+ // promoted mid-transfer. The checksum moves with the installed head so
+ // a new primary extends the same prepare chain as the other replicas.
consensus.sequencer().set_sequence(commit_op);
+ if let Some(checksum) = offsets_wire.prepare_checksum {
+ consensus.set_last_prepare_checksum(checksum);
+ }
consensus.clear_pipeline();
consensus.advance_commit_max(commit_op);
self.observed_view = self.consensus().view();
diff --git a/core/partitions/src/types.rs b/core/partitions/src/types.rs
index 0c720dd..acb6bdf 100644
--- a/core/partitions/src/types.rs
+++ b/core/partitions/src/types.rs
@@ -336,27 +336,21 @@
}
/// Where partition directories live on disk, mirroring the server's
-/// `SystemConfig` path scheme so segment files created by the partition plane
+/// `ServerConfig` path scheme so segment files created by the partition plane
/// land next to the ones the server bootstrap created.
#[derive(Debug, Clone)]
pub struct PartitionPathLayout {
- /// `{system.path}/{stream.path}`: the directory holding per-stream dirs.
+ /// `{path}/streams`: the directory holding per-stream dirs.
pub streams_root: String,
- /// Directory name of the per-topic level (`topic.path`).
- pub topics_dir: String,
- /// Directory name of the per-partition level (`partition.path`).
- pub partitions_dir: String,
}
/// Synthetic layout for tests and the simulator, where paths only key the
/// sim storage and never touch a real filesystem. The server always wires
-/// the real layout from its `SystemConfig`.
+/// the real layout from its `ServerConfig`.
impl Default for PartitionPathLayout {
fn default() -> Self {
Self {
streams_root: "/tmp/iggy_stub/streams".to_string(),
- topics_dir: "topics".to_string(),
- partitions_dir: "partitions".to_string(),
}
}
}
@@ -364,7 +358,7 @@
/// Configuration for partition operations.
///
/// Mirrors the relevant fields from the server's `PartitionConfig` and
-/// `SegmentConfig` (`core/server/src/configs/system.rs`).
+/// the server partition configuration and resolved topic options.
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct PartitionsConfig {
@@ -372,12 +366,6 @@
pub messages_required_to_save: u32,
/// Flush journal to disk when it accumulates this many bytes.
pub size_of_messages_required_to_save: IggyByteSize,
- /// Whether to enforce fsync after writes.
- pub enforce_fsync: bool,
- /// Whether consumer-offset files are written crash-safe (data-synced,
- /// renamed, directory synced). Independent of `enforce_fsync`, which
- /// governs message and index files.
- pub consumer_offset_enforce_fsync: bool,
/// Whether a disk poll verifies each batch's `batch_checksum` against the bytes
/// it just read.
///
@@ -409,10 +397,8 @@
partition_id: usize,
) -> String {
format!(
- "{}/{stream_id}/{}/{topic_id}/{}/{partition_id}",
+ "{}/{stream_id}/topics/{topic_id}/partitions/{partition_id}",
self.path_layout.streams_root,
- self.path_layout.topics_dir,
- self.path_layout.partitions_dir,
)
}
diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml
index 9399358..76fff19 100644
--- a/core/sdk/Cargo.toml
+++ b/core/sdk/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy"
-version = "0.11.0-edge.7"
+version = "0.11.0-edge.8"
description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
edition = "2024"
rust-version.workspace = true
diff --git a/core/sdk/src/http/messages.rs b/core/sdk/src/http/messages.rs
index 90cd4bd..d12f76b 100644
--- a/core/sdk/src/http/messages.rs
+++ b/core/sdk/src/http/messages.rs
@@ -67,35 +67,10 @@
partitioning: &Partitioning,
messages: &mut [IggyMessage],
) -> Result<SendMessagesResponse, IggyError> {
- let batch = IggyMessagesBatch::from(&*messages);
let response = self
- .post(
- &get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
- &SendMessages {
- metadata_length: 0, // this field is used only for TCP/QUIC
- stream_id: stream_id.clone(),
- topic_id: topic_id.clone(),
- partitioning: partitioning.clone(),
- batch,
- },
- )
+ .post_messages(stream_id, topic_id, partitioning, messages)
.await?;
- let body = response
- .bytes()
- .await
- .map_err(|_| IggyError::InvalidBytesResponse)?;
- // The legacy server answers a successful send with 201 and no content
- // at all. That is not JSON, and it must not read as a decode failure on
- // a write that already committed: no body means the batch landed with
- // no offsets reported, which is an empty list.
- if body.is_empty() {
- return Ok(SendMessagesResponse {
- confirmations: Vec::new(),
- });
- }
- let confirmations: SendMessagesConfirmations =
- serde_json::from_slice(&body).map_err(|_| IggyError::InvalidJsonResponse)?;
- Ok(SendMessagesResponse::from(confirmations))
+ decode_send_response(response).await
}
async fn flush_unsaved_buffer(
@@ -123,6 +98,76 @@
}
}
+impl HttpClient {
+ /// Send messages and expose the completion guarantee advertised by HTTP.
+ /// An absent or unrecognized header returns None without turning a
+ /// committed write into a retryable failure.
+ ///
+ /// # Errors
+ /// Returns a request or confirmation-decoding error.
+ pub async fn send_messages_with_durability(
+ &self,
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+ partitioning: &Partitioning,
+ messages: &mut [IggyMessage],
+ ) -> Result<(SendMessagesResponse, Option<iggy_common::Durability>), IggyError> {
+ let response = self
+ .post_messages(stream_id, topic_id, partitioning, messages)
+ .await?;
+ let durability = response
+ .headers()
+ .get("iggy-durability")
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.parse().ok());
+ Ok((decode_send_response(response).await?, durability))
+ }
+
+ async fn post_messages(
+ &self,
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+ partitioning: &Partitioning,
+ messages: &mut [IggyMessage],
+ ) -> Result<reqwest::Response, IggyError> {
+ let batch = IggyMessagesBatch::from(&*messages);
+ let response = self
+ .post(
+ &get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
+ &SendMessages {
+ metadata_length: 0, // this field is used only for TCP/QUIC
+ stream_id: stream_id.clone(),
+ topic_id: topic_id.clone(),
+ partitioning: partitioning.clone(),
+ batch,
+ },
+ )
+ .await?;
+ Ok(response)
+ }
+}
+
+async fn decode_send_response(
+ response: reqwest::Response,
+) -> Result<SendMessagesResponse, IggyError> {
+ let body = response
+ .bytes()
+ .await
+ .map_err(|_| IggyError::InvalidBytesResponse)?;
+ // The legacy server answers a successful send with 201 and no content
+ // at all. That is not JSON, and it must not read as a decode failure on
+ // a write that already committed: no body means the batch landed with
+ // no offsets reported, which is an empty list.
+ if body.is_empty() {
+ return Ok(SendMessagesResponse {
+ confirmations: Vec::new(),
+ });
+ }
+ let confirmations: SendMessagesConfirmations =
+ serde_json::from_slice(&body).map_err(|_| IggyError::InvalidJsonResponse)?;
+ Ok(SendMessagesResponse::from(confirmations))
+}
+
fn get_path(stream_id: &str, topic_id: &str) -> String {
format!("streams/{stream_id}/topics/{topic_id}/messages")
}
diff --git a/core/sdk/src/http/topics.rs b/core/sdk/src/http/topics.rs
index 365707b..60f73be 100644
--- a/core/sdk/src/http/topics.rs
+++ b/core/sdk/src/http/topics.rs
@@ -85,7 +85,7 @@
// has no dedicated field for them, and dropping them here
// gave one transport a topic without the fsync the caller
// asked for while the other honored it.
- options: options.to_string_options(),
+ options: options.to_string_options()?,
},
)
.await?;
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index f239eec..c16c643 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -53,10 +53,10 @@
Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics, CacheMetricsKey, ClientError,
ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus,
CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember,
- ConsumerKind, Credentials, EncryptorKind, GlobalPermissions, HeaderField, HeaderKey,
- HeaderKind, HeaderValue, HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind,
- Identifier, IdentityInfo, IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView,
- IggyMessage, IggyMessageHeader, IggyMessageHeaderView, IggyMessageView,
+ ConsumerKind, Credentials, Durability, EncryptorKind, GlobalPermissions, HeaderField,
+ HeaderKey, HeaderKind, HeaderValue, HttpClientConfig, HttpClientConfigBuilder, HttpMethod,
+ IdKind, Identifier, IdentityInfo, IggyByteSize, IggyDuration, IggyError, IggyExpiry,
+ IggyIndexView, IggyMessage, IggyMessageHeader, IggyMessageHeaderView, IggyMessageView,
IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, NonZeroDurationError,
NonZeroIggyDuration, OptionSpec, OptionValue, OptionsScope, Partition, Partitioner,
Partitioning, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages,
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index 553d8d9..d112462 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "server"
-version = "0.9.0-edge.7"
+version = "0.9.0-edge.8"
edition = "2024"
license = "Apache-2.0"
publish = false
diff --git a/core/server/config.toml b/core/server/config.toml
index 55a3ad5..9d51eb0 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -15,6 +15,9 @@
# specific language governing permissions and limitations
# under the License.
+# Root directory for server data. Environment override: IGGY_PATH.
+path = "local_data"
+
# Configuration for consumer group cooperative partition rebalancing.
[consumer_group]
# Maximum time a partition can remain in pending revocation before being force-transferred to the target member.
@@ -28,16 +31,6 @@
# Interval for running the message cleaner.
interval = "1 m"
-# This node's own client-facing identity, used when 'cluster.enabled' is false.
-[node]
-# A literal IP or a DNS hostname, without a port. Named to match its roster
-# counterpart 'cluster.nodes.advertised_address', which answers the same
-# question per node. The unspecified address ("0.0.0.0", "::") is rejected: it
-# is the bind address's answer to a question clients are not asking. Commented
-# out here because the shipped 'tcp.address' binds a concrete address and needs
-# no declaration.
-#advertised_address = "broker-1.example.com"
-
# HTTP server configuration
[http]
# Determines if the HTTP server is active.
@@ -362,18 +355,13 @@
endpoint = "http://localhost:7281/v1/traces"
# System configuration.
-[system]
-# Base path for system data storage.
-path = "local_data"
-
-# Runtime configuration.
-[system.runtime]
+[runtime]
# Path for storing runtime data.
-# Specifies the directory where any runtime data is stored, relative to `system.path`.
+# Specifies the directory where any runtime data is stored, relative to `path`.
path = "runtime"
# Logging configuration.
-[system.logging]
+[logging]
# Path for storing log files.
path = "logs"
@@ -384,7 +372,7 @@
level = "info"
# Whether to write logs to file. When false, logs are only written to stdout.
-# When enabled, logs are stored in {system.path}/{system.logging.path} (default: local_data/logs).
+# When enabled, logs are stored in {path}/{logging.path} (default: local_data/logs).
file_enabled = true
# Maximum size of a single log file before rotation occurs. When a log
@@ -408,7 +396,7 @@
retention = "7 days"
# Encryption configuration
-[system.encryption]
+[encryption]
# Determines whether server-side data encryption for the messages payloads and state commands is enabled (boolean).
# `true` enables encryption for stored data using AES-256-GCM.
# `false` means data is stored without encryption.
@@ -419,157 +407,8 @@
# This key is required and used only if encryption is enabled.
key = ""
-# Stream configuration
-[system.stream]
-# Path for storing stream-related data (string).
-# Specifies the directory where stream data is stored, relative to `system.path`.
-path = "streams"
-
-# Topic configuration
-[system.topic]
-# Path for storing topic-related data, relative to `stream.path`.
-path = "topics"
-
-# Retention is per topic, set at CreateTopic and readable on GetTopic:
-# max_topic_size - delete oldest sealed segments past this size
-# ("unlimited" when unset)
-# message_expiry - delete sealed segments older than this ("none" when unset)
-# Both policies can be active at once; the active segment is never touched.
-# Call GET /options/topic (or the SDK's describe_options) for the full catalog
-# with this server's defaults.
-
-# Partition configuration
-[system.partition]
-# Path for storing partition-related data (string).
-# Specifies the directory where partition data is stored, relative to `topic.path`.
-path = "partitions"
-
-# Enables checksum validation for data integrity (boolean).
-# `true` re-hashes every batch a disk poll reads and fails the poll closed on a
-# mismatch, so a segment damaged at rest is reported instead of served.
-# `false` skips the re-hash and serves whatever decodes, which hands a consumer
-# bytes provably not the ones written. Only turn it off with a corruption guard
-# somewhere else in the stack.
-validate_checksum = true
-
-# Durability and flush cadence are per topic, set at CreateTopic:
-# enforce_fsync - fdatasync each flush (false when unset)
-# messages_required_to_save - flush after this many messages (1024)
-# size_of_messages_required_to_save - flush after this many bytes (1 MiB)
-# enforce_fsync decides whether a flush syncs, never whether one happens. A
-# write is acknowledged when it commits, which precedes its flush unless that
-# commit trips a threshold, and the partition journal holding the unflushed
-# remainder is in memory and is not replayed at boot. Acks are therefore
-# durability-gated only at messages_required_to_save = 1.
-
-# Segment configuration
-[system.segment]
-# Segment size is per topic, set at CreateTopic as `segment_size` (1 GiB when
-# unset). It is a soft limit: a segment may close one whole batch past it.
-# Bounds are enforced at creation - a 512 B multiple, at least 1 MiB, and no
-# larger than 1 GiB.
-#
-# `preallocate_segments` is per topic too: it reserves exactly `segment_size`
-# up front where the filesystem supports it. Off unless a topic asks for it --
-# with the default 1 GiB segment size it costs 1 GiB of real disk per
-# partition at creation.
-
-# Configures whether expired segments are archived (boolean) or just deleted without archiving.
-# Unsupported: setting this to `true` aborts boot.
-archive_expired = false
-
-# Recovery configuration in case of lost data
-[system.recovery]
-# Controls whether streams/topics/partitions should be recreated if the expected data for existing state is missing (boolean).
-# Unsupported: setting this to `true` aborts boot.
-recreate_missing_state = false
-
-# At boot, segment recovery walks each partition's segments: bytes after the
-# last decodable batch of a genuinely torn tail are physically truncated from
-# the .log/.index files. Every index entry is derived from a batch the log
-# already held, so an index is never evidence about the log: one the log
-# contradicts is repaired, not believed. It is DROPPED whole and rebuilt from
-# a byte-0 walk when it holds no whole 24-byte entry, when its entries
-# regress in offset or position, when an entry does not match the log batch's
-# offset, timestamp, partition and position, when its first entry is not the
-# segment's own start offset at position 0, or when the log cannot back its
-# LAST entry. Only an index whose
-# last entry the log proves anchors a walk, which then runs from that entry
-# to the file end. Under the topic's enforce_fsync = true an index the log
-# cannot back is measured against the log first: a gap of more than ONE entry
-# refuses the partition instead of rebuilding, because fsync lets the index
-# outrun the log by at most the chunk in flight at the crash, so a wider gap
-# is previously durable data the log lost.
-#
-# The byte-0 walk is deliberately not narrowed to the entries the log still
-# proves. An anchor picked that way can sit ABOVE damage, and the residue
-# probe only ever looks forward from where a walk stopped, so a hole under
-# the anchor would go unread while end_offset still advertised the offsets
-# over it. Reaching that branch means the index and the log already disagree,
-# so the extra bytes read are bounded by the segments a crash actually tore.
-#
-# Every batch a walk accepts passes its own checksum. With enforce_fsync =
-# false, the index only locates data because page-cache writeback can preserve
-# a later log page while losing an earlier one, so recovery walks every segment
-# from byte 0. A clean walk preserves the existing index and performs no disk
-# mutation. With enforce_fsync = true, completed serialized log fdatasyncs
-# prove the prefix before the last index entry, so a healthy boot starts at
-# that last entry and leaves earlier at-rest damage to the read path's
-# validate_checksum. Wherever a recovery walk finds damage, it truncates only
-# when nothing decodable follows and refuses when something does.
-#
-# The non-fsync verification pass therefore reads and checksums every log byte
-# at boot, although it keeps a clean index in place. A byte-0 repair walk also
-# rebuilds a missing or contradicted index durably, adding two fsyncs per
-# repaired segment. Either path can take minutes over thousands of segments.
-# A restore that dropped every .index repairs every segment; an ordinary
-# fsynced crash usually repairs only its tail segment. A slow boot is therefore
-# not by itself evidence of a stall. Before, the repair shapes refused boot.
-#
-# Either walk refuses a verifying batch whose base offset does not continue
-# the chain, or whose partition_id stamp is not this partition's. The one
-# exception is the first batch at an index-derived anchor: the offset it
-# fails to match is the ENTRY's, not the log's, so a mismatch there breaks
-# the walk as a stale entry, leaving the verdict to the byte-0 rebuild. A
-# batch that fails its own checksum is damage rather than data
-# whatever its fields claim: the walk stops there and the residue goes
-# through the damage probe like any other torn tail, so a bit flip in a tail
-# batch's offset truncates instead of refusing the partition, regardless of
-# the flip's direction.
-#
-# Damage in the middle of a segment is never silently truncated: the
-# partition is refused, as is trailing residue whose verification cost
-# exhausts the probe's residue-derived work budgets while walked batches
-# exist (residue after a walk that proved nothing recovers the segment as
-# empty instead, fencing the file pair aside whole). With peer replicas the
-# refused files are moved to a .fenced.N directory beside the partition,
-# which is rebuilt empty and refilled from a peer. With replica_count = 1
-# there is no peer, so nothing is moved and nothing is rebuilt: the refused
-# files stay at their original paths, the partition is tombstoned (unrouted,
-# clients get the retriable transient status, never an empty poll that would
-# read as a healthy empty partition), and the same refusal is re-derived and
-# re-logged on every boot. The tombstone lifts in exactly two ways: a
-# chain-shape refusal whose planned chain provably holds ZERO recoverable
-# bytes rebuilds through .fenced.N at boot, and deleting the
-# stream/topic/partition removes the refused files and clears the fence, so
-# a recreate of the same ids starts clean. One refusal is not durable: a
-# length divergence found when a writer reopens a file recovery just
-# truncated clears on the next boot, so that tombstone lasts only for the
-# life of the process.
-#
-# .fenced.N has a second producer, on a partition that recovers FINE: a tail
-# segment holding bytes that decode to nothing anywhere is moved there and
-# the segment recovered empty, so the partition serves without those bytes.
-# One fresh directory per such segment, so .fenced.0, .fenced.1, ... can
-# accumulate under a healthy partition. .fenced.N alone therefore does not
-# mean a partition was refused -- grep the boot log for "refusing the
-# recovered segment chain", which names the directory and the reason.
-#
-# The metadata WAL truncates genuinely torn tails too; interior WAL damage
-# or oversized trailing bytes refuse boot instead.
-
# Memory pool configuration
-[system.memory_pool]
+[memory_pool]
# Enables or disables the memory pool (boolean).
# `true` enables the memory pool.
# `false` disables the memory pool.
@@ -861,7 +700,7 @@
# ports = { tcp = 8095, quic = 8082, http = 3002, websocket = 8094, tcp_replica = 9092 }
# Sharding configuration
-[system.sharding]
+[sharding]
# CPU allocation - controls the number of shards and their CPU affinity.
# Possible values:
# - "all": Use all available CPU cores (default)
@@ -900,7 +739,7 @@
# the per-shard watchdog and the parallel-join survivor path; sized
# larger than typical TCP RTT times in-flight write-batch so writers
# receive their full last `write_vectored_all` budget before the
-# connection registry force-tears the bus. Slow-fsync hosts may need
+# connection registry force-tears the bus. Hosts with slow durable writes may need
# to extend this past the default.
shutdown_drain_timeout = "10 s"
@@ -1005,6 +844,72 @@
# plane), a pipeline exists per partition, so raising this multiplies pinned
# request-buffer memory by the partition count. Keep it modest.
[partition]
+# Retention is per topic, set at CreateTopic and readable on GetTopic:
+# max_topic_size - delete oldest sealed segments past this size
+# ("unlimited" when unset)
+# message_expiry - delete sealed segments older than this ("none" when unset)
+# Both policies can be active at once. The active segment is never touched.
+# WAL-backed partitions request a checkpoint before deleting referenced segments;
+# retention waits for that checkpoint, even below the usual WAL capacity trigger.
+# Call GET /options/topic (or the SDK's describe_options) for the full catalog
+# with this server's defaults.
+
+# Topic durability and flush scheduling are set at creation and returned by GetTopic.
+# durability controls message completion. consumer_offset_durability controls explicit offset stores and deletes.
+# Both independently default to "replicated". Neither inherits the other.
+# In replicated groups, either persisted policy enables WAL references to segment bodies, retained by hard link until reclamation.
+# This includes replicated messages with persisted offsets. Full message payloads count against wal_bytes_max.
+# Their barriers can also persist co-batched messages. Only replicated/replicated stays off this WAL.
+# Persisted prepares are limited to 64 MiB including the consensus header. Oversized operations are rejected permanently.
+# Both policies write data to storage. "replicated" waits for VSR commit and application without an additional stable-storage barrier.
+# "persisted" also requires recoverable stable-storage copies at the replication quorum before explicit success.
+# A singleton quorum is one replica. Its persisted completion follows recoverable local persistence.
+# Poll auto-commit remains asynchronous. A successful poll does not confirm that its submitted offset operation committed or became durable.
+# HTTP ack=none returns dispatch acceptance with Iggy-Durability: none, even for persisted topics.
+# messages_required_to_save defaults to 1024 and size_of_messages_required_to_save defaults to 1 MiB.
+# These thresholds schedule ordinary segment writes. Required persistence, capacity pressure and lifecycle operations can flush sooner.
+
+# Segment storage
+# Segment size is per topic, set at CreateTopic as `segment_size` (1 GiB when
+# unset). It is a soft limit: a segment may close one whole batch past it.
+# Bounds are enforced at creation - a 512 B multiple, at least 1 MiB, and no
+# larger than 1 GiB.
+#
+# `preallocate_segments` is per topic too: it reserves exactly `segment_size`
+# up front where the filesystem supports it. Off unless a topic asks for it --
+# with the default 1 GiB segment size it costs 1 GiB of real disk per
+# partition at creation.
+
+# Recovery behavior
+# Recovery validates message batches against their checksums and partition identity before serving data.
+# A genuinely incomplete tail can be truncated. Interior damage or valid records after a damaged range require refusal or repair, never blind truncation.
+# A sparse index that contradicts the message log is rebuilt only when the discrepancy fits the storage guarantee.
+# With message durability = "replicated", background writeback can preserve later pages before earlier ones, so recovery checks the log from byte zero.
+# With message durability = "persisted", completed durable segment flushes constrain the incomplete tail and permit the established index-anchored recovery path.
+# Durable prepares can still reside in the partition WAL rather than segments. Recovery reconciles that history with the materialized state before serving.
+# Index reconstruction and validation can take time across many segments. A slow recovery is not by itself evidence of a stalled server.
+# Unrecoverable local segment data is fenced and repaired from peers when available. A singleton cannot fetch a missing copy from another replica.
+# Fenced files remain available for diagnosis. Recovery must not replace an acknowledged durable history with an empty healthy partition.
+# Metadata and partition journals verify their own integrity and recovery frontiers independently of sparse indexes.
+
+# Per-partition active WAL and queued/in-flight prepare budget when either
+# topic policy is persisted. A 4 KiB multiple, from 128 MiB + 8 KiB to 4 GiB.
+# The minimum accommodates the retained commit-point prepare and a maximum-size successor.
+# Checkpointing begins at half the byte budget or 1024 dirty files.
+# File barriers run with at most 16 concurrent operations. Temporary rewrites need extra disk
+# space. Lowering this budget preserves existing history and blocks new writes
+# until recovery and checkpointing reclaim enough space.
+# Checkpoints synchronize changed files through the partition writer. Other
+# partitions continue while this partition defers materialized commit application.
+# The HTTP metrics endpoint exposes per-shard partition_wal_* byte gauges and
+# completed batch, prepare, checkpoint and error counters.
+# partition_wal_retained_bytes plus queued_bytes and in_flight_bytes measures budget usage;
+# partition_wal_disk_bytes measures WAL file length, excluding referenced segment bodies.
+wal_bytes_max = "256 MiB"
+
+# Verify the checksum of batches read from segment storage. A mismatch is reported rather than served.
+validate_checksum = true
+
# Depth of a partition's prepare queue: how many uncommitted produce /
# consumer-offset ops may be in flight at once for that partition. Submits past
# it spill into a request queue of twice this depth; once both are full the
@@ -1044,22 +949,16 @@
# Must be > 0 and <= 262144.
consumer_offsets_max = 4096
-# Whether consumer-offset files are written crash-safe. On, every committed
-# offset store writes a sibling file, fdatasyncs it and renames it over the
-# prior cursor, and the offsets directory is fsynced once per commit walk before
-# any reply in that walk is sent. Independent of the topic's enforce_fsync,
-# which governs message and index files. Off, the file is rewritten in place
-# with no sync. A lost or torn cursor can cause replay from the earliest retained
-# data. On, the filesystem must support both file and directory sync. A failed
-# required barrier is reported as a failure, even if the mutation is visible.
-# The environment override is IGGY_PARTITION_CONSUMER_OFFSET_ENFORCE_FSYNC.
-consumer_offset_enforce_fsync = false
+# Consumer-offset durability is selected per topic by consumer_offset_durability.
+# It independently defaults to replicated, just like message durability.
+# Poll auto-commit submits an offset write asynchronously. A poll response does
+# not confirm that this write committed or became durable.
# How many offsets a partition claims in its superblock ahead of the mint
# counter before it will append, so a crash-restarted replica resumes above
# every offset it confirmed to a client instead of re-minting it for a different
-# message. One superblock write (two fsyncs) per block: lowering it raises the
-# fsync rate on the write path, raising it wastes at most one block of the u64
+# message. One durable superblock update per block: lowering it raises the
+# durable-write rate on the write path, raising it wastes at most one block of the u64
# offset space per crash. Must be > 0 and <= 16777216.
#
# SINGLE-REPLICA groups only. A replicated group acks a send once a quorum has
@@ -1159,3 +1058,13 @@
# connecting.await + accept_bi.await) so a slowloris peer cannot pin
# per-conn channels + registry slot + spawned task indefinitely.
handshake_grace = "10 s"
+
+# This node's own client-facing identity, used when 'cluster.enabled' is false.
+[node]
+# A literal IP or a DNS hostname, without a port. Named to match its roster
+# counterpart 'cluster.nodes.advertised_address', which answers the same
+# question per node. The unspecified address ("0.0.0.0", "::") is rejected: it
+# is the bind address's answer to a question clients are not asking. Commented
+# out here because the shipped 'tcp.address' binds a concrete address and needs
+# no declaration.
+#advertised_address = "broker-1.example.com"
diff --git a/core/server/src/args.rs b/core/server/src/args.rs
index b037896..0317bfd 100644
--- a/core/server/src/args.rs
+++ b/core/server/src/args.rs
@@ -53,12 +53,12 @@
named by IGGY_ENV_PATH.
Common examples:
- IGGY_SYSTEM_PATH=/data/iggy # Data directory
+ IGGY_PATH=/data/iggy # Data directory
IGGY_TCP_ADDRESS=127.0.0.1:8090 # TCP listener address
IGGY_HTTP_ADDRESS=0.0.0.0:3000 # HTTP listener address
IGGY_NODE_ADVERTISED_ADDRESS=localhost # Address clients dial, required
# when a listener binds a wildcard
- IGGY_SYSTEM_LOGGING_LEVEL=debug # Log level
+ IGGY_LOGGING_LEVEL=debug # Log level
IGGY_ROOT_USERNAME=iggy # Root user, set with the password
IGGY_ROOT_PASSWORD=secret # Root password, set with the username
@@ -90,7 +90,7 @@
/// Remove the system path before starting (WARNING: THIS WILL DELETE ALL DATA!)
///
/// Deletes the configured system data directory ('local_data' by default,
- /// see IGGY_SYSTEM_PATH) before the server boots, so it starts on empty
+ /// see IGGY_PATH) before the server boots, so it starts on empty
/// state. Intended for clean development setups and testing.
///
/// In cluster mode this wipes THIS replica only; it rejoins and refills by
diff --git a/core/server/src/boot/credentials.rs b/core/server/src/boot/credentials.rs
index aeff093..1b3ec3b 100644
--- a/core/server/src/boot/credentials.rs
+++ b/core/server/src/boot/credentials.rs
@@ -145,7 +145,7 @@
// already stored. `--fresh` has already wiped by this point, so a wiped
// replica is correctly treated as a first boot.
let fresh_cluster = config.cluster.enabled
- && !Path::new(&config.system.path)
+ && !Path::new(&config.path)
.join(metadata::impls::METADATA_DIR)
.exists();
diff --git a/core/server/src/boot/listeners.rs b/core/server/src/boot/listeners.rs
index 222b5d7..9ffb8c2 100644
--- a/core/server/src/boot/listeners.rs
+++ b/core/server/src/boot/listeners.rs
@@ -152,7 +152,7 @@
&config.http,
config.metadata.clients_table_max,
config.personal_access_token.max_tokens_per_user,
- Arc::clone(&config.system),
+ Arc::new(config.clone()),
roster,
shard_metrics_all,
)?;
diff --git a/core/server/src/boot/mod.rs b/core/server/src/boot/mod.rs
index 04d2db3..ca55df8 100644
--- a/core/server/src/boot/mod.rs
+++ b/core/server/src/boot/mod.rs
@@ -69,7 +69,7 @@
ServerMetadata, ServerMetadataBundle, ServerMuxStateMachine, ShellBus, ShellHandlers,
ShellShardHandle,
};
-use configs::server::{ServerConfig, ServerSystemConfig};
+use configs::server::ServerConfig;
use consensus::{MetadataHandle, PartitionsHandle};
use iggy_binary_protocol::{Operation, PrepareHeader};
use journal::superblock::SuperblockStore;
@@ -105,7 +105,7 @@
pub fn wire_shell_handlers<B, MJ, S, SB>(
bus: &B,
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
- system_config: Arc<ServerSystemConfig>,
+ server_config: Arc<ServerConfig>,
max_tokens_per_user: u32,
) -> ShellHandlers
where
@@ -122,7 +122,7 @@
bus,
shard_handle,
&sessions,
- system_config,
+ server_config,
max_tokens_per_user,
),
on_metadata_submit: make_metadata_submit_handler(shard_handle),
@@ -161,9 +161,9 @@
if fresh {
wipe_system_path(config).await?;
}
- create_directories(&config.system).await.map_err(|source| {
+ create_directories(config).await.map_err(|source| {
error!(
- system_path = %config.system.get_system_path(),
+ system_path = %config.get_system_path(),
error = %source,
"failed to prepare server directories"
);
@@ -171,8 +171,8 @@
})?;
logging
.late_init(
- config.system.get_system_path(),
- &LoggingSettings::from(&config.system.logging),
+ config.get_system_path(),
+ &LoggingSettings::from(&config.logging),
&TelemetrySettings::from(&config.telemetry),
)
.map_err(ServerError::Logging)?;
@@ -182,8 +182,8 @@
/// Delete the configured system path so the server boots on empty state.
async fn wipe_system_path(config: &ServerConfig) -> Result<(), ServerError> {
- let path = config.system.get_system_path();
- // `system.path` is relative by default and IGGY_SYSTEM_PATH-overridable,
+ let path = config.get_system_path();
+ // `path` is relative by default and IGGY_PATH-overridable,
// so report what is actually about to be deleted, not what was configured.
let resolved = std::path::absolute(&path).unwrap_or_else(|_| PathBuf::from(&path));
@@ -216,7 +216,7 @@
/// Spawn the multi-shard `server` runtime.
///
/// Resolves shard count + CPU affinities from
-/// `system.sharding.cpu_allocation`, builds canonical-ordered
+/// `sharding.cpu_allocation`, builds canonical-ordered
/// `(senders, inboxes)` channels, and spawns one OS thread per shard.
///
/// Each thread pins itself (`nix::sched::sched_setaffinity` on Linux via
@@ -258,8 +258,8 @@
warm_dummy_password_hash();
// The sync GetStats read path has no access to server config, so capture
// the data directory here for its disk-usage reporting.
- crate::responses::init_stats_data_path(config.system.get_system_path().into());
- let (assignments, total_shards) = resolve_shard_assignments(&config.system.sharding)?;
+ crate::responses::init_stats_data_path(config.get_system_path().into());
+ let (assignments, total_shards) = resolve_shard_assignments(&config.sharding)?;
let shards_count = assignments.len();
// Re-check the full valid range, not just the zero floor: a caller
@@ -267,9 +267,9 @@
// would otherwise OOM at boot allocating an oversized inbox channel,
// busy-loop every shutdown watchdog on a zero poll cadence, or wedge
// process exit on an unbounded drain budget.
- let inbox_capacity = config.system.sharding.inbox_capacity;
- let reply_inbox_capacity = config.system.sharding.reply_inbox_capacity;
- validate_sharding_runtime_knobs(&config.system.sharding)?;
+ let inbox_capacity = config.sharding.inbox_capacity;
+ let reply_inbox_capacity = config.sharding.reply_inbox_capacity;
+ validate_sharding_runtime_knobs(&config.sharding)?;
let (senders, mut inboxes, mut reply_inboxes) =
shard_mesh_channels(total_shards, inbox_capacity, reply_inbox_capacity);
@@ -287,13 +287,12 @@
// the drain alone is short by a poll interval, and
// `shutdown_join_timeout == shutdown_drain_timeout` is a legal config.
let shutdown_deadline = Arc::new(ShutdownDeadline::new(
- config.system.sharding.shutdown_join_timeout.get_duration(),
+ config.sharding.shutdown_join_timeout.get_duration(),
config
- .system
.sharding
.shutdown_drain_timeout
.get_duration()
- .saturating_add(config.system.sharding.shutdown_poll_interval.get_duration()),
+ .saturating_add(config.sharding.shutdown_poll_interval.get_duration()),
));
// One owner table per server process, Arc-cloned into every shard's bus so
// any shard's bus reads the same atomic slots that the owning
@@ -491,8 +490,8 @@
tls: load_replica_tls_ctx(config, &topology)?.map(Rc::new),
});
- let drain_timeout = config.system.sharding.shutdown_drain_timeout.get_duration();
- let poll_interval = config.system.sharding.shutdown_poll_interval.get_duration();
+ let drain_timeout = config.sharding.shutdown_drain_timeout.get_duration();
+ let poll_interval = config.sharding.shutdown_poll_interval.get_duration();
let shutdown_flag_for_handoff = Arc::clone(&shutdown_flag);
let mut shutdown_watchdog = Some(spawn_shutdown_watchdog(
@@ -509,7 +508,7 @@
// WAL access, no replay. Writes still funnel through shard 0's
// metadata VSR; per-commit `publish()` (in `WriteCell::apply`)
// bounds reader staleness to one op.
- let data_dir = Path::new(&config.system.path);
+ let data_dir = Path::new(&config.path);
// The bundle broadcast is deliberately NOT inside the owner arm: it is
// the first moment a peer can hold a read handle over shard 0's writer,
// so the writer must first be parked in a binding that outlives the peer
@@ -633,7 +632,7 @@
snapshot_for_metadata,
superblock_for_metadata,
Rc::clone(&mux_stm),
- Some(PathBuf::from(&config.system.path)),
+ Some(PathBuf::from(&config.path)),
)
.with_applied_frontier(metadata_applied_frontier);
metadata.seed_applied_frontier_from_consensus();
@@ -791,11 +790,7 @@
topology.self_replica_id,
topology.replica_count,
));
- let reconcile_periodic = config
- .system
- .sharding
- .reconcile_periodic_interval
- .get_duration();
+ let reconcile_periodic = config.sharding.reconcile_periodic_interval.get_duration();
let reconciler_handle = compio::runtime::spawn({
let ctx = Rc::clone(&reconciler_ctx);
async move {
diff --git a/core/server/src/boot/recovery.rs b/core/server/src/boot/recovery.rs
index 25a7b67..03f41a9 100644
--- a/core/server/src/boot/recovery.rs
+++ b/core/server/src/boot/recovery.rs
@@ -81,6 +81,30 @@
roster_cells: &RosterCells,
) -> Result<ShardBuild, ServerError> {
let shard_local_id = ShardId::new(shard_id);
+ let retired_key: iggy_common::HeaderKey =
+ "enforce_fsync".parse().expect("retired key is valid");
+ let enabled = iggy_common::HeaderValue::from(true);
+ let retired_durability = metadata.mux_stm.streams().read(|inner| {
+ inner.items.iter().find_map(|(stream_id, stream)| {
+ stream.topics.iter().find_map(|(topic_id, topic)| {
+ topic
+ .options
+ .get(&retired_key)
+ .is_some_and(|option| option.value == enabled)
+ .then_some((stream_id, topic_id))
+ })
+ })
+ });
+ if let Some((stream_id, topic_id)) = retired_durability {
+ error!(
+ stream_id,
+ topic_id,
+ "stored topic uses removed enforce_fsync=true. Recreate it with explicit durability in a data directory prepared for this version. No legacy durability translation is performed"
+ );
+ return Err(ServerError::Iggy(Box::new(
+ iggy_common::IggyError::UnsupportedOptionKey("enforce_fsync".to_owned()),
+ )));
+ }
let total_partitions = metadata.mux_stm.streams().read(|inner| {
inner
.items
@@ -107,8 +131,8 @@
// At-rest encryption: built once per shard from the shared config; the
// ingestion path encrypts on the primary and the poll reply decrypts.
// A bad key fails the boot rather than silently serving plaintext.
- let encryptor = if config.system.encryption.enabled {
- let aes = Aes256GcmEncryptor::from_base64_key(&config.system.encryption.key)
+ let encryptor = if config.encryption.enabled {
+ let aes = Aes256GcmEncryptor::from_base64_key(&config.encryption.key)
.map_err(|error| ServerError::Iggy(Box::new(error)))?;
Some(Arc::new(EncryptorKind::Aes256Gcm(aes)))
} else {
@@ -121,16 +145,13 @@
size_of_messages_required_to_save: IggyByteSize::from(
iggy_common::DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
),
- enforce_fsync: iggy_common::DEFAULT_ENFORCE_FSYNC,
- consumer_offset_enforce_fsync: config.partition.consumer_offset_enforce_fsync,
- validate_checksum: config.system.partition.validate_checksum,
+
+ validate_checksum: config.partition.validate_checksum,
segment_size: IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
preallocate_segments: iggy_common::DEFAULT_PREALLOCATE_SEGMENTS,
encryptor,
path_layout: partitions::PartitionPathLayout {
- streams_root: config.system.get_streams_path(),
- topics_dir: config.system.topic.path.clone(),
- partitions_dir: config.system.partition.path.clone(),
+ streams_root: config.get_streams_path(),
},
},
owned_partitions_capacity,
@@ -242,7 +263,7 @@
} = wire_shell_handlers(
&bus,
&shard_handle,
- Arc::clone(&config.system),
+ Arc::new(config.clone()),
config.personal_access_token.max_tokens_per_user,
);
sessions
@@ -662,6 +683,33 @@
use super::*;
#[test]
+ fn partition_runtime_and_server_use_the_same_fixed_directory_layout() {
+ let server = ServerConfig {
+ path: "/var/lib/iggy".to_owned(),
+ ..ServerConfig::default()
+ };
+ let partition = PartitionsConfig {
+ messages_required_to_save: iggy_common::DEFAULT_MESSAGES_REQUIRED_TO_SAVE,
+ size_of_messages_required_to_save: IggyByteSize::from(
+ iggy_common::DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
+ ),
+ validate_checksum: server.partition.validate_checksum,
+ segment_size: IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
+ preallocate_segments: iggy_common::DEFAULT_PREALLOCATE_SEGMENTS,
+ encryptor: None,
+ path_layout: partitions::PartitionPathLayout {
+ streams_root: server.get_streams_path(),
+ },
+ };
+ for (stream, topic, id) in [(0, 0, 0), (1, 2, 3), (23, 45, 67)] {
+ assert_eq!(
+ server.get_partition_path(stream, topic, id),
+ partition.get_partition_path(stream, topic, id)
+ );
+ }
+ }
+
+ #[test]
fn superblock_fatal_window_converts_to_capped_backoff_retries() {
assert_eq!(
superblock_window_to_failures(Duration::ZERO),
@@ -1004,6 +1052,24 @@
}
#[test]
+ fn wal_capacity_defaults_and_bounds_match_journal() {
+ assert_eq!(
+ configs::partition::PartitionConfig::default()
+ .wal_bytes_max
+ .as_bytes_u64(),
+ journal::partition_journal::PARTITION_WAL_BYTES_MAX
+ );
+ assert_eq!(
+ configs::partition::MIN_PARTITION_WAL_BYTES_MAX,
+ journal::partition_journal::PARTITION_WAL_CAPACITY_MIN
+ );
+ assert_eq!(
+ configs::partition::MAX_PARTITION_WAL_BYTES_MAX,
+ journal::partition_journal::PARTITION_WAL_CAPACITY_MAX
+ );
+ }
+
+ #[test]
fn default_offset_reservation_lease_matches_partitions_constant() {
// `IggyPartition::new` falls back to the partitions constant (simulator,
// unit tests) while boot installs this one, so drift would have the
diff --git a/core/server/src/boot/threads.rs b/core/server/src/boot/threads.rs
index 83c4e40..4aaf0cc 100644
--- a/core/server/src/boot/threads.rs
+++ b/core/server/src/boot/threads.rs
@@ -48,7 +48,7 @@
/// per shard, and the first panic `install_panic_hook` recorded. The
/// caller flips the flag via [`Self::install_ctrlc_handler`] and then
/// drains every shard via [`Self::join_all`], bounded by the shared
-/// `ShutdownDeadline` (`system.sharding.shutdown_join_timeout`).
+/// `ShutdownDeadline` (`sharding.shutdown_join_timeout`).
pub struct ShardHandles {
pub(in crate::boot) shutdown_flag: Arc<AtomicBool>,
pub(in crate::boot) shard_threads: Vec<(u16, thread::JoinHandle<Result<(), ServerError>>)>,
@@ -336,7 +336,7 @@
/// The single post-shutdown budget, shared by the main thread's shard
/// joins and shard 0's peer wait.
///
-/// Both waits are bounded by `system.sharding.shutdown_join_timeout` and
+/// Both waits are bounded by `sharding.shutdown_join_timeout` and
/// they NEST: shard 0 cannot start waiting for its peers until its own
/// drain returned, which is already inside the join budget. Arming one
/// instant on first use, whichever wait gets there first, keeps the two
@@ -730,7 +730,7 @@
let Some(pump_handle) = pump_handle else {
return Ok(());
};
- let drain_budget = config.system.sharding.shutdown_drain_timeout.get_duration();
+ let drain_budget = config.sharding.shutdown_drain_timeout.get_duration();
let Ok(join_result) = compio::time::timeout(drain_budget, pump_handle).await else {
error!(
shard = shard_id,
@@ -1127,10 +1127,7 @@
async fn pump_drain_timeout_is_not_reported_as_clean() {
let mut config = ServerConfig::default();
let timeout = Duration::from_millis(1);
- Arc::get_mut(&mut config.system)
- .expect("a fresh ServerConfig owns its system config")
- .sharding
- .shutdown_drain_timeout = iggy_common::IggyDuration::new(timeout);
+ config.sharding.shutdown_drain_timeout = iggy_common::IggyDuration::new(timeout);
let pump = compio::runtime::spawn(std::future::pending::<Option<FatalCommit>>());
let error = await_pump_drain(Some(pump), &config, 7)
diff --git a/core/server/src/config_writer.rs b/core/server/src/config_writer.rs
index 6c91a03..77c5009 100644
--- a/core/server/src/config_writer.rs
+++ b/core/server/src/config_writer.rs
@@ -103,7 +103,7 @@
node.ports.http = bound_ports.http.or(node.ports.http);
}
- let runtime_path = current_config.system.get_runtime_path();
+ let runtime_path = current_config.get_runtime_path();
let config_path = format!("{runtime_path}/current_config.toml");
let content = toml::to_string(¤t_config).map_err(ServerError::CurrentConfigSerialize)?;
diff --git a/core/server/src/consumer_group.rs b/core/server/src/consumer_group.rs
index abeca57..0150be4 100644
--- a/core/server/src/consumer_group.rs
+++ b/core/server/src/consumer_group.rs
@@ -78,7 +78,7 @@
let wire = WireJoinConsumerGroupRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
let in_flight =
- gather_in_flight(shard, &wire.stream_id, &wire.topic_id, &wire.group_id).await;
+ gather_in_flight(shard, &wire.stream_id, &wire.topic_id, &wire.group_id).await?;
ReplicatedJoinConsumerGroupRequest {
stream_id: wire.stream_id,
topic_id: wire.topic_id,
@@ -107,17 +107,17 @@
/// Gather the group's in-flight partitions (`last_polled` present and
/// `committed < last_polled`) for the cooperative-rebalance classification. A
-/// not-yet-created group, an unresolved topic, or a partition that does not
-/// answer is treated as not-in-flight (eager handoff). An eager handoff leaves
-/// no `PendingRevocation` record, so the reconciler never revisits it -- a
-/// misclassification here just redelivers the uncommitted range to the new
-/// owner, which is correct under at-least-once.
+/// not-yet-created group, an unresolved topic, or an absent partition reply
+/// is treated as not-in-flight (eager handoff, allowing at-least-once replay).
+/// An explicit rejection aborts the join before replication: missing local
+/// materialization cannot establish whether an existing owner has drained.
+/// A retry gathers ownership and offsets again before clearing stale marks.
async fn gather_in_flight<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
group_id: &WireIdentifier,
-) -> Vec<u32>
+) -> Result<Vec<u32>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
@@ -129,10 +129,10 @@
let Some(monotonic_group_id) = streams.resolve_consumer_group_id(stream_id, topic_id, group_id)
else {
// Fresh group (e.g. create-if-not-exists): nothing polled yet.
- return Vec::new();
+ return Ok(Vec::new());
};
let Some(partition_ids) = streams.topic_partition_ids(stream_id, topic_id) else {
- return Vec::new();
+ return Ok(Vec::new());
};
// Partitions a live member currently owns. A `last_polled` past the commit
// only means in-flight work when a live member still holds the partition;
@@ -169,6 +169,9 @@
let mut in_flight = Vec::new();
let mut stale_clears = Vec::new();
for (partition_id, ns, reply) in results {
+ if let Some(PartitionReadReply::Rejected(error)) = reply {
+ return Err(error);
+ }
let Some(PartitionReadReply::GroupOffsetState {
last_polled: Some(polled),
committed,
@@ -194,7 +197,7 @@
}
// Fire the stale-mark clears concurrently too; the result is unused.
futures::future::join_all(stale_clears).await;
- in_flight
+ Ok(in_flight)
}
/// Rewrite a group consumer-offset op so its consumer id is the group's
@@ -258,3 +261,623 @@
rewrite_request_body(&request, &rewritten)
}
+
+#[cfg(test)]
+mod tests {
+ use std::cell::RefCell;
+ use std::future::Future;
+ use std::path::Path;
+ use std::sync::Arc;
+
+ use bytes::Bytes;
+ use consensus::{Consensus, LocalPipeline, PartitionsHandle, Sequencer, VsrConsensus};
+ use futures::future::{Either, select};
+ use iggy_binary_protocol::batch::BATCH_HEADER_SIZE;
+ use iggy_binary_protocol::primitives::ack_level::AckLevel;
+ use iggy_binary_protocol::primitives::consumer::WireConsumer;
+ use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
+ use iggy_binary_protocol::requests::consumer_groups::CreateConsumerGroupRequest;
+ use iggy_binary_protocol::requests::streams::CreateStreamRequest;
+ use iggy_binary_protocol::requests::topics::{
+ CreateTopicRequest, CreateTopicWithAssignmentsRequest,
+ };
+ use iggy_binary_protocol::{WireName, WireOptions};
+ use iggy_common::{
+ ConsumerGroupId, ConsumerGroupOffsets, ConsumerKind, ConsumerOffset, ConsumerOffsets,
+ PartitionStats,
+ };
+ use metadata::stm::StateMachine;
+ use partitions::state_transfer::mark_materialization_missing;
+ use partitions::{IggyIndexWriter, IggyPartition, MessagesWriter, PartitionsConfig};
+ use server_common::SegmentStorage;
+ use server_common::send_messages::{
+ IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
+ };
+ use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
+ use shard::shards_table::ShardsTable;
+ use shard::{LifecycleFrame, Receiver, ShardFrame, shard_channel};
+
+ use super::*;
+ use crate::dispatch::partition::make_partition_read_handler;
+ use crate::dispatch::test_support::{
+ SpyBus, TestShard, prepare_message, request_message, test_shard,
+ };
+
+ const STREAM_ID: WireIdentifier = WireIdentifier::Numeric(0);
+ const TOPIC_ID: WireIdentifier = WireIdentifier::Numeric(0);
+ const GROUP_ID: WireIdentifier = WireIdentifier::Numeric(0);
+ const FIRST_CLIENT: u128 = 11;
+ const SECOND_CLIENT: u128 = 22;
+ const STALE_PARTITION: u32 = 0;
+ const RECOVERING_PARTITION: u32 = 1;
+ const PARTITION_COUNT: u32 = 2;
+ const INBOX_CAPACITY: usize = 16;
+ const LAST_POLLED_OFFSET: u64 = 10;
+ const COMMITTED_OFFSET: u64 = 5;
+
+ #[compio::test]
+ async fn given_rejected_join_when_recovered_should_retry_without_stale_revocations() {
+ let (shard, inbox) = group_shard();
+ let directory = tempfile::tempdir().unwrap();
+ let stale_namespace = namespace(&shard, STALE_PARTITION);
+ let recovering_namespace = namespace(&shard, RECOVERING_PARTITION);
+ let stale = partition(&shard, STALE_PARTITION, &directory.path().join("stale"));
+ record_last_polled(&stale);
+ shard.plane.partitions().insert(stale_namespace, stale);
+ let recovering_dir = directory.path().join("recovering");
+ let mut recovering = partition(&shard, RECOVERING_PARTITION, &recovering_dir);
+ mark_materialization_missing(recovering_dir.to_str().unwrap(), 0)
+ .await
+ .unwrap();
+ recovering.open_persistence().await.unwrap();
+ assert!(recovering.requires_state_transfer());
+ shard
+ .plane
+ .partitions()
+ .insert(recovering_namespace, recovering);
+
+ let streams = shard.plane.metadata().mux_stm.streams();
+ let before = streams
+ .consumer_group_details(&STREAM_ID, &TOPIC_ID, &GROUP_ID)
+ .unwrap();
+ let rejected = with_partition_reads(
+ &shard,
+ &inbox,
+ maybe_rewrite_consumer_group_request(&shard, join_request(FIRST_CLIENT)),
+ )
+ .await;
+ assert!(matches!(rejected, Err(IggyError::TransientNotAccepted)));
+ assert_eq!(
+ streams.consumer_group_details(&STREAM_ID, &TOPIC_ID, &GROUP_ID),
+ Some(before)
+ );
+ assert_eq!(
+ shard
+ .plane
+ .partitions()
+ .group_offset_state(&stale_namespace, 0),
+ Some((Some(LAST_POLLED_OFFSET), None)),
+ "rejected join must not execute queued stale clears"
+ );
+
+ recover_partition(&shard, &directory.path().join("donor")).await;
+ let accepted = with_partition_reads(
+ &shard,
+ &inbox,
+ maybe_rewrite_consumer_group_request(&shard, join_request(FIRST_CLIENT)),
+ )
+ .await
+ .unwrap();
+ assert!(
+ ReplicatedJoinConsumerGroupRequest::decode_from(request_body(&accepted))
+ .unwrap()
+ .in_flight
+ .is_empty()
+ );
+ apply_join(&shard, &accepted);
+ assert_eq!(
+ shard
+ .plane
+ .partitions()
+ .group_offset_state(&stale_namespace, 0),
+ Some((None, None)),
+ "successful retry clears the orphan's last-polled mark"
+ );
+ assert_eq!(
+ streams
+ .consumer_group_member_assignment(&STREAM_ID, &TOPIC_ID, &GROUP_ID, FIRST_CLIENT)
+ .unwrap()
+ .1,
+ vec![STALE_PARTITION, RECOVERING_PARTITION]
+ );
+
+ let next_join = with_partition_reads(
+ &shard,
+ &inbox,
+ maybe_rewrite_consumer_group_request(&shard, join_request(SECOND_CLIENT)),
+ )
+ .await
+ .unwrap();
+ assert!(
+ ReplicatedJoinConsumerGroupRequest::decode_from(request_body(&next_join))
+ .unwrap()
+ .in_flight
+ .is_empty(),
+ "the next join must not misclassify the previously orphaned partition"
+ );
+ apply_join(&shard, &next_join);
+ assert!(!streams.has_pending_revocations());
+ assert_eq!(
+ streams
+ .consumer_group_member_assignment(&STREAM_ID, &TOPIC_ID, &GROUP_ID, SECOND_CLIENT)
+ .unwrap()
+ .1,
+ vec![RECOVERING_PARTITION]
+ );
+ }
+
+ #[compio::test]
+ async fn given_transferring_partition_when_joining_should_preserve_commit_ownership() {
+ for missing in [true, false] {
+ let (shard, inbox) = group_shard();
+ apply_initial_join(&shard);
+ let directory = tempfile::tempdir().unwrap();
+ let mut recovering = partition(&shard, RECOVERING_PARTITION, directory.path());
+ record_last_polled(&recovering);
+ recovering.consumer_group_offsets.pin().insert(
+ ConsumerGroupId(0),
+ ConsumerOffset::new(
+ ConsumerKind::ConsumerGroup,
+ 0,
+ COMMITTED_OFFSET,
+ String::new(),
+ ),
+ );
+ if missing {
+ mark_materialization_missing(directory.path().to_str().unwrap(), 0)
+ .await
+ .unwrap();
+ recovering.open_persistence().await.unwrap();
+ } else {
+ recovering.consensus().begin_state_transfer_await();
+ }
+ assert!(recovering.consensus().is_transferring());
+ assert_eq!(recovering.requires_state_transfer(), missing);
+ shard
+ .plane
+ .partitions()
+ .insert(namespace(&shard, RECOVERING_PARTITION), recovering);
+ let streams = shard.plane.metadata().mux_stm.streams();
+ let before = streams
+ .consumer_group_details(&STREAM_ID, &TOPIC_ID, &GROUP_ID)
+ .unwrap();
+ let assignment = streams
+ .consumer_group_member_assignment(&STREAM_ID, &TOPIC_ID, &GROUP_ID, FIRST_CLIENT)
+ .unwrap();
+
+ let rewritten = with_partition_reads(
+ &shard,
+ &inbox,
+ maybe_rewrite_consumer_group_request(&shard, join_request(SECOND_CLIENT)),
+ )
+ .await;
+ if missing {
+ assert!(matches!(rewritten, Err(IggyError::TransientNotAccepted)));
+ assert_eq!(
+ streams.consumer_group_details(&STREAM_ID, &TOPIC_ID, &GROUP_ID),
+ Some(before)
+ );
+ assert_eq!(
+ streams.consumer_group_member_assignment(
+ &STREAM_ID,
+ &TOPIC_ID,
+ &GROUP_ID,
+ FIRST_CLIENT
+ ),
+ Some(assignment),
+ "rejection must preserve generation and the existing owner's assignment"
+ );
+ assert!(
+ streams
+ .consumer_group_member_assignment(
+ &STREAM_ID,
+ &TOPIC_ID,
+ &GROUP_ID,
+ SECOND_CLIENT
+ )
+ .is_none()
+ );
+ assert!(!streams.has_pending_revocations());
+ } else {
+ let rewritten = rewritten.unwrap();
+ assert_eq!(
+ ReplicatedJoinConsumerGroupRequest::decode_from(request_body(&rewritten))
+ .unwrap()
+ .in_flight,
+ vec![RECOVERING_PARTITION]
+ );
+ apply_join(&shard, &rewritten);
+ assert!(streams.has_pending_revocations());
+ assert_eq!(
+ streams.consumer_group_fence(
+ &STREAM_ID,
+ &TOPIC_ID,
+ &GROUP_ID,
+ FIRST_CLIENT,
+ RECOVERING_PARTITION,
+ true
+ ),
+ None,
+ "cooperative revocation stops new polls while the owner drains"
+ );
+ }
+ assert_eq!(
+ streams.consumer_group_fence(
+ &STREAM_ID,
+ &TOPIC_ID,
+ &GROUP_ID,
+ FIRST_CLIENT,
+ RECOVERING_PARTITION,
+ false
+ ),
+ Some(0),
+ "the existing owner must retain permission to commit its in-flight batch"
+ );
+ assert_eq!(
+ streams.consumer_group_fence(
+ &STREAM_ID,
+ &TOPIC_ID,
+ &GROUP_ID,
+ SECOND_CLIENT,
+ RECOVERING_PARTITION,
+ false
+ ),
+ None
+ );
+ }
+ }
+
+ #[compio::test]
+ async fn given_unanswered_or_missing_partitions_when_joining_should_allow_eager_handoff() {
+ let (shard, inbox) = group_shard();
+ apply_initial_join(&shard);
+ let unanswered = namespace(&shard, STALE_PARTITION);
+ shard.shards_table().remove(&unanswered);
+ assert!(
+ shard
+ .partition_read(unanswered, PartitionRead::GroupOffsetState { group_id: 0 })
+ .await
+ .is_none()
+ );
+ let not_found = with_partition_reads(
+ &shard,
+ &inbox,
+ shard.partition_read(
+ namespace(&shard, RECOVERING_PARTITION),
+ PartitionRead::GroupOffsetState { group_id: 0 },
+ ),
+ )
+ .await;
+ assert!(matches!(not_found, Some(PartitionReadReply::NotFound)));
+
+ let rewritten = with_partition_reads(
+ &shard,
+ &inbox,
+ maybe_rewrite_consumer_group_request(&shard, join_request(SECOND_CLIENT)),
+ )
+ .await
+ .unwrap();
+ assert!(
+ ReplicatedJoinConsumerGroupRequest::decode_from(request_body(&rewritten))
+ .unwrap()
+ .in_flight
+ .is_empty()
+ );
+ apply_join(&shard, &rewritten);
+ let streams = shard.plane.metadata().mux_stm.streams();
+ assert!(!streams.has_pending_revocations());
+ assert_eq!(
+ streams
+ .consumer_group_member_assignment(&STREAM_ID, &TOPIC_ID, &GROUP_ID, SECOND_CLIENT)
+ .unwrap()
+ .1,
+ vec![RECOVERING_PARTITION]
+ );
+ }
+
+ fn group_shard() -> (Rc<TestShard>, Receiver<ShardFrame>) {
+ let bus = SpyBus::default();
+ let mut shard = test_shard(&bus, 0, 3, 1);
+ let (sender, inbox, _replies) = shard_channel(0, INBOX_CAPACITY, INBOX_CAPACITY);
+ shard.attach_senders(vec![sender]);
+ let shard = Rc::new(shard);
+ let mux = &shard.plane.metadata().mux_stm;
+ mux.update(prepare_message(
+ Operation::CreateStream,
+ FIRST_CLIENT,
+ 1,
+ &CreateStreamRequest {
+ name: WireName::new("stream").unwrap(),
+ options: WireOptions::empty(),
+ }
+ .to_bytes(),
+ ))
+ .unwrap();
+ mux.update(prepare_message(
+ Operation::CreateTopicWithAssignments,
+ FIRST_CLIENT,
+ 2,
+ &CreateTopicWithAssignmentsRequest {
+ request: CreateTopicRequest {
+ stream_id: STREAM_ID,
+ partitions_count: PARTITION_COUNT,
+ name: WireName::new("topic").unwrap(),
+ options: WireOptions::empty(),
+ },
+ derived_options: WireOptions::empty(),
+ partitions: (0..PARTITION_COUNT)
+ .map(|partition_id| CreatedPartitionAssignment {
+ partition_id,
+ consensus_group_id: u64::from(partition_id) + 1,
+ })
+ .collect(),
+ created_view: 0,
+ }
+ .to_bytes(),
+ ))
+ .unwrap();
+ mux.update(prepare_message(
+ Operation::CreateConsumerGroup,
+ FIRST_CLIENT,
+ 3,
+ &CreateConsumerGroupRequest {
+ stream_id: STREAM_ID,
+ topic_id: TOPIC_ID,
+ name: WireName::new("group").unwrap(),
+ }
+ .to_bytes(),
+ ))
+ .unwrap();
+ for partition_id in 0..PARTITION_COUNT {
+ shard.shards_table().insert(
+ namespace(&shard, partition_id),
+ PartitionLocation::new(ShardId::new(0), 0),
+ );
+ }
+ (shard, inbox)
+ }
+
+ fn namespace(shard: &Rc<TestShard>, partition_id: u32) -> IggyNamespace {
+ resolve_partition_namespace(shard, &STREAM_ID, &TOPIC_ID, Some(partition_id)).unwrap()
+ }
+
+ fn partition(
+ shard: &Rc<TestShard>,
+ partition_id: u32,
+ directory: &Path,
+ ) -> IggyPartition<SpyBus> {
+ let consensus = VsrConsensus::new(
+ 1,
+ 0,
+ 3,
+ namespace(shard, partition_id).inner(),
+ SpyBus::default(),
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ let mut partition = IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ shard.plane.partitions().config().segment_size,
+ );
+ let consumers = directory.join("offsets/consumers");
+ let groups = directory.join("offsets/groups");
+ std::fs::create_dir_all(&consumers).unwrap();
+ std::fs::create_dir_all(&groups).unwrap();
+ partition.set_partition_dir(directory.to_string_lossy().into_owned());
+ partition.configure_consumer_offset_storage(
+ consumers.to_string_lossy().into_owned(),
+ groups.to_string_lossy().into_owned(),
+ ConsumerOffsets::with_capacity(0),
+ ConsumerGroupOffsets::with_capacity(1),
+ );
+ partition
+ }
+
+ fn record_last_polled(partition: &IggyPartition<SpyBus>) {
+ partition.last_polled_offsets.pin().insert(
+ ConsumerGroupId(0),
+ ConsumerOffset::new(
+ ConsumerKind::ConsumerGroup,
+ 0,
+ LAST_POLLED_OFFSET,
+ String::new(),
+ ),
+ );
+ }
+
+ fn join_request(client: u128) -> Message<RoutedRequestHeader> {
+ request_message(
+ Operation::JoinConsumerGroup,
+ client,
+ 1,
+ 1,
+ &WireJoinConsumerGroupRequest {
+ stream_id: STREAM_ID,
+ topic_id: TOPIC_ID,
+ group_id: GROUP_ID,
+ }
+ .to_bytes(),
+ )
+ }
+
+ fn apply_initial_join(shard: &Rc<TestShard>) {
+ shard
+ .plane
+ .metadata()
+ .mux_stm
+ .update(prepare_message(
+ Operation::JoinConsumerGroup,
+ FIRST_CLIENT,
+ 4,
+ &ReplicatedJoinConsumerGroupRequest {
+ stream_id: STREAM_ID,
+ topic_id: TOPIC_ID,
+ group_id: GROUP_ID,
+ client_id: FIRST_CLIENT,
+ in_flight: Vec::new(),
+ }
+ .to_bytes(),
+ ))
+ .unwrap();
+ }
+
+ fn apply_join(shard: &Rc<TestShard>, request: &Message<RoutedRequestHeader>) {
+ shard
+ .plane
+ .metadata()
+ .mux_stm
+ .update(prepare_message(
+ Operation::JoinConsumerGroup,
+ request.header().client,
+ request.header().request,
+ request_body(request),
+ ))
+ .unwrap();
+ }
+
+ async fn with_partition_reads<T>(
+ shard: &Rc<TestShard>,
+ inbox: &Receiver<ShardFrame>,
+ operation: impl Future<Output = T>,
+ ) -> T {
+ let handle = Rc::new(RefCell::new(Some(Rc::downgrade(shard))));
+ let handler = make_partition_read_handler(&handle);
+ let serve = async {
+ loop {
+ let ShardFrame::Lifecycle(LifecycleFrame::PartitionRead {
+ namespace,
+ read,
+ reply,
+ }) = inbox.recv().await.unwrap()
+ else {
+ panic!("unexpected frame while serving the join's partition reads");
+ };
+ handler(namespace, read, reply);
+ }
+ };
+ match select(Box::pin(operation), Box::pin(serve)).await {
+ Either::Left((result, _)) => result,
+ Either::Right(_) => {
+ unreachable!("partition read service runs until the request completes")
+ }
+ }
+ }
+
+ async fn recover_partition(shard: &Rc<TestShard>, donor_dir: &Path) {
+ let mut donor = partition(shard, RECOVERING_PARTITION, donor_dir);
+ attach_segment_files(&mut donor, donor_dir).await;
+ let body = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer_group(GROUP_ID),
+ stream_id: STREAM_ID,
+ topic_id: TOPIC_ID,
+ partition_id: Some(RECOVERING_PARTITION),
+ offset: COMMITTED_OFFSET,
+ ack: AckLevel::Quorum,
+ }
+ .to_bytes();
+ let config = shard.plane.partitions().config();
+ commit_donor_prepare(&mut donor, config, Operation::StoreConsumerOffset, &body).await;
+ let mut messages =
+ IggyMessages::with_capacity(usize::try_from(COMMITTED_OFFSET + 1).unwrap());
+ for _ in 0..=COMMITTED_OFFSET {
+ messages.push(IggyMessage {
+ header: IggyMessageHeader::default(),
+ payload: Bytes::from_static(b"recovered"),
+ user_headers: None,
+ });
+ }
+ let batch =
+ SendMessagesOwned::from_messages(namespace(shard, RECOVERING_PARTITION), &messages)
+ .unwrap();
+ let mut body = vec![0; batch.header.total_size()];
+ batch.header.encode_into(&mut body);
+ body[BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ commit_donor_prepare(&mut donor, config, Operation::SendMessages, &body).await;
+ assert_eq!(donor.group_offset_state(0).1, Some(COMMITTED_OFFSET));
+ let offer = donor.state_transfer_offer(config).await.unwrap();
+ assert_eq!(offer.segments.len(), 1);
+ let recovering_namespace = namespace(shard, RECOVERING_PARTITION);
+ let recovering = shard
+ .plane
+ .partitions()
+ .get_mut_by_ns(&recovering_namespace)
+ .unwrap();
+ let segment = &offer.segments[0];
+ let staged = recovering
+ .spill_transfer_segment(&segment.entry, std::fs::read(&segment.log_path).unwrap())
+ .await
+ .unwrap();
+ recovering
+ .install_state_transfer(config, offer.commit_op, vec![staged], &offer.offsets.1, 0)
+ .await
+ .unwrap();
+ assert!(!recovering.requires_state_transfer());
+ assert_eq!(recovering.group_offset_state(0).1, Some(COMMITTED_OFFSET));
+ }
+
+ async fn commit_donor_prepare(
+ donor: &mut IggyPartition<SpyBus>,
+ config: &PartitionsConfig,
+ operation: Operation,
+ body: &[u8],
+ ) {
+ let op = donor.consensus().sequencer().current_sequence() + 1;
+ let prepare = prepare_message(operation, FIRST_CLIENT, op, body).transmute_header(
+ |original, header: &mut PrepareHeader| {
+ *header = original;
+ header.cluster = 1;
+ header.group = donor.consensus().group();
+ header.op = op;
+ header.parent = donor.consensus().last_prepare_checksum();
+ },
+ );
+ let prepare = consensus::seal_prepare_checksum(prepare);
+ donor.consensus().sequencer().set_sequence(op);
+ donor
+ .consensus()
+ .set_last_prepare_checksum(prepare.header().checksum);
+ donor.on_replicate(prepare).await;
+ donor.consensus().advance_commit_max(op);
+ donor.commit_journal(config).await;
+ assert_eq!(donor.consensus().commit_min(), op);
+ }
+
+ async fn attach_segment_files(partition: &mut IggyPartition<SpyBus>, directory: &Path) {
+ let start_offset = partition.log.active_segment().start_offset;
+ let messages_path = directory
+ .join(format!("{start_offset:020}.log"))
+ .to_string_lossy()
+ .into_owned();
+ let index_path = directory
+ .join(format!("{start_offset:020}.index"))
+ .to_string_lossy()
+ .into_owned();
+ let storage = SegmentStorage::new(&messages_path, &index_path, 0, 0, false)
+ .await
+ .unwrap();
+ let messages_size = storage.messages_writer.as_ref().unwrap().size_counter();
+ let index_size = storage.index_writer.as_ref().unwrap().size_counter();
+ partition.log.messages_writers_mut()[0] = Some(Rc::new(
+ MessagesWriter::new(&messages_path, messages_size, false, false, None)
+ .await
+ .unwrap(),
+ ));
+ partition.log.index_writers_mut()[0] = Some(Rc::new(
+ IggyIndexWriter::new(&index_path, index_size, false, false)
+ .await
+ .unwrap(),
+ ));
+ *partition.log.active_storage_mut() = storage;
+ }
+}
diff --git a/core/server/src/dispatch/failure.rs b/core/server/src/dispatch/failure.rs
index 37bb5a5..3501221 100644
--- a/core/server/src/dispatch/failure.rs
+++ b/core/server/src/dispatch/failure.rs
@@ -372,7 +372,7 @@
};
use crate::responses::build_empty_reply;
use crate::session_manager::SessionManager;
- use configs::server::ServerSystemConfig;
+ use configs::server::ServerConfig;
use iggy_binary_protocol::Operation;
use iggy_binary_protocol::codes::PING_CODE;
use iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL;
@@ -595,7 +595,7 @@
async fn snapshot_reply_frame_unchanged() {
let (bus, shard) = snapshot_shard();
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
let request = request_message(Operation::NonReplicated, VSR_CLIENT, SESSION, REQUEST, &[])
.transmute_header(|header, ping: &mut RoutedRequestHeader| {
*ping = header;
@@ -615,7 +615,7 @@
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
request.into_generic(),
diff --git a/core/server/src/dispatch/mod.rs b/core/server/src/dispatch/mod.rs
index 662c250..5a5e9a2 100644
--- a/core/server/src/dispatch/mod.rs
+++ b/core/server/src/dispatch/mod.rs
@@ -39,7 +39,7 @@
pub mod session_ops;
pub mod submit;
#[cfg(test)]
-mod test_support;
+pub mod test_support;
use crate::consumer_group::maybe_rewrite_consumer_group_request;
use crate::dispatch::failure::{
@@ -58,7 +58,7 @@
use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
use crate::wire::verify_request_checksum;
use ahash::{AHashMap, AHashSet};
-use configs::server::ServerSystemConfig;
+use configs::server::ServerConfig;
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::codes::{
LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE,
@@ -140,7 +140,7 @@
bus: &B,
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
sessions: &Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
+ server_config: Arc<ServerConfig>,
max_tokens_per_user: u32,
) -> RequestHandler
where
@@ -194,7 +194,7 @@
&bus_for_spawn,
&shard_handle,
&sessions,
- &system_config,
+ &server_config,
max_tokens_per_user,
&queues,
&active,
@@ -247,7 +247,7 @@
bus: &B,
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
sessions: &Rc<RefCell<SessionManager>>,
- system_config: &Arc<ServerSystemConfig>,
+ server_config: &Arc<ServerConfig>,
max_tokens_per_user: u32,
queues: &ClientRequestQueues,
active: &ActiveClientRequests,
@@ -278,7 +278,7 @@
let shard_handle = Rc::clone(shard_handle);
let sessions = Rc::clone(sessions);
- let system_config = Arc::clone(system_config);
+ let server_config = Arc::clone(server_config);
let queues = Rc::clone(queues);
let active = Rc::clone(active);
bus.spawn(async move {
@@ -292,7 +292,7 @@
drain_client_requests(
shard,
sessions,
- system_config,
+ server_config,
max_tokens_per_user,
queues,
client_id,
@@ -380,7 +380,7 @@
async fn drain_client_requests<B, MJ, S, SB>(
shard: Rc<ShellShard<B, MJ, S, SB>>,
sessions: Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
+ server_config: Arc<ServerConfig>,
max_tokens_per_user: u32,
queues: ClientRequestQueues,
client_id: u128,
@@ -398,7 +398,7 @@
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
max_tokens_per_user,
client_id,
message,
@@ -519,7 +519,7 @@
async fn handle_client_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
- system_config: &Arc<ServerSystemConfig>,
+ server_config: &Arc<ServerConfig>,
max_tokens_per_user: u32,
transport_client_id: u128,
message: Message<iggy_binary_protocol::GenericHeader>,
@@ -653,7 +653,7 @@
handle_non_replicated_request(
shard,
sessions,
- system_config,
+ server_config,
transport_client_id,
request,
(user_id, client_address, metadata_watermark),
@@ -759,12 +759,8 @@
let request = match maybe_rewrite_consumer_group_request(shard, request).await {
Ok(rewritten) => rewritten,
Err(error) => {
- // Both of the rewrite's own failures are `InvalidCommand`
- // decode errors, so a replay cannot help: deny typed
- // instead of leaving the lockstep connection to its read
- // timeout. (Its third error path needs a body past
- // `u32::MAX` against a 64 MiB message cap, so no client
- // frame reaches it; the deny is correct there too.)
+ // Preserve transient recovery rejection so the client can
+ // retry the join once partition state is available.
send_pre_consensus_deny(
shard,
transport_client_id,
@@ -908,8 +904,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
@@ -1080,7 +1075,7 @@
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
let multi_node = Rc::new(ClusterRoster {
enabled: true,
@@ -1107,7 +1102,7 @@
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
metadata_read(),
@@ -1352,13 +1347,13 @@
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
for code in [LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE] {
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
non_replicated_request(TRANSPORT, code),
@@ -1393,12 +1388,12 @@
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
wire_request(Operation::CreateStream, TRANSPORT, 1, 1, &[]).into_generic(),
@@ -1428,12 +1423,12 @@
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
non_replicated_request(TRANSPORT, PING_CODE),
@@ -1461,7 +1456,7 @@
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
+ let server_config = Arc::new(ServerConfig::default());
let mut message = wire_request(Operation::CreateStream, TRANSPORT, 1, 1, BODY);
{
@@ -1476,7 +1471,7 @@
handle_client_request(
&shard,
&sessions,
- &system_config,
+ &server_config,
1,
TRANSPORT,
message.into_generic(),
@@ -1519,7 +1514,7 @@
&bus,
&unset_shard_handle(),
&Rc::new(RefCell::new(SessionManager::new())),
- Arc::new(ServerSystemConfig::default()),
+ Arc::new(ServerConfig::default()),
1,
);
assert_eq!(
@@ -1543,7 +1538,7 @@
&bus,
&shard_handle,
&Rc::new(RefCell::new(SessionManager::new())),
- Arc::new(ServerSystemConfig::default()),
+ Arc::new(ServerConfig::default()),
1,
);
diff --git a/core/server/src/dispatch/partition.rs b/core/server/src/dispatch/partition.rs
index 76fcd98..de74599 100644
--- a/core/server/src/dispatch/partition.rs
+++ b/core/server/src/dispatch/partition.rs
@@ -104,6 +104,16 @@
return;
};
let partitions = shard.plane.partitions();
+ if partitions.with_partition(
+ &namespace,
+ partitions::IggyPartition::requires_state_transfer,
+ ) == Some(true)
+ {
+ let _ = reply.try_send(PartitionReadReply::Rejected(
+ IggyError::TransientNotAccepted,
+ ));
+ return;
+ }
match read {
PartitionRead::Poll { consumer, args } => {
match partitions.build_poll_snapshot(&namespace, consumer, &args) {
@@ -911,6 +921,11 @@
stored: Some(stored_offset),
current_offset,
}) => build_consumer_offset_body(partition_id, current_offset, stored_offset),
+ Some(PartitionReadReply::Rejected(error)) => {
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code())
+ .await;
+ return;
+ }
_ => Bytes::new(),
}
}
@@ -1366,6 +1381,7 @@
);
return Err(IggyError::TransientNotAccepted);
}
+ Some(PartitionReadReply::Rejected(error)) => return Err(error),
other => {
debug!(
client_id,
@@ -1393,6 +1409,8 @@
use crate::dispatch::test_support::{
SpyBus, TestMux, TestShard, prepare_message, request_message, test_shard,
};
+ #[cfg(target_os = "linux")]
+ use consensus::Sequencer;
use iggy_binary_protocol::ReplyHeader;
use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
use iggy_binary_protocol::requests::consumer_offsets::DeleteConsumerOffsetRequest;
@@ -1416,6 +1434,112 @@
ShardIdentity, shard_channel,
};
+ #[cfg(target_os = "linux")]
+ #[compio::test]
+ async fn checkpoint_index_failure_is_returned_by_the_same_partition_tick() {
+ let root = tempfile::tempdir().unwrap();
+ let bus = SpyBus::default();
+ let shard = test_shard(&bus, 0, 3, 1);
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let consensus = consensus::VsrConsensus::new(
+ 1,
+ 0,
+ 3,
+ namespace.inner(),
+ bus,
+ consensus::LocalPipeline::new(),
+ );
+ consensus.init();
+ let mut partition = partitions::IggyPartition::with_in_memory_storage(
+ std::sync::Arc::new(iggy_common::PartitionStats::default()),
+ consensus,
+ shard.plane.partitions().config().segment_size,
+ );
+ partition.set_runtime_options(iggy_common::TopicRuntimeOptions {
+ durability: iggy_common::Durability::Persisted,
+ preallocate_segments: Some(false),
+ ..Default::default()
+ });
+ partition.set_partition_dir(root.path().to_string_lossy().into_owned());
+ let capacity = journal::partition_journal::PARTITION_WAL_BYTES_MAX;
+ let (persistence, prepares) = partitions::PartitionPersistence::open_with_capacity(
+ &root.path().join("prepares-0"),
+ namespace.inner(),
+ 0,
+ journal::durable_storage::DiskStorage,
+ capacity,
+ false,
+ )
+ .await
+ .unwrap();
+ partition
+ .open_persistence_with_recovered(capacity, Some((Rc::clone(&persistence), prepares)))
+ .await
+ .unwrap();
+
+ let mut messages = server_common::send_messages::IggyMessages::with_capacity(1);
+ messages.push(server_common::send_messages::IggyMessage {
+ header: server_common::send_messages::IggyMessageHeader::default(),
+ payload: Bytes::from_static(b"checkpoint"),
+ user_headers: None,
+ });
+ let batch =
+ server_common::send_messages::SendMessagesOwned::from_messages(namespace, &messages)
+ .unwrap();
+ let mut body = vec![0; batch.header.total_size()];
+ batch.header.encode_into(&mut body);
+ body[iggy_binary_protocol::batch::BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ let prepare = prepare_message(Operation::SendMessages, 1, 1, &body).transmute_header(
+ |original, header: &mut PrepareHeader| {
+ *header = original;
+ header.cluster = 1;
+ header.group = namespace.inner();
+ header.checksum = header.identity_checksum();
+ },
+ );
+ let checksum = prepare.header().checksum;
+ persistence
+ .append(prepare.clone().into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.into_frozen())
+ .await
+ .unwrap();
+ partition.log.journal_mut().info.messages_count = 1;
+ partition.log.journal_mut().info.size = iggy_common::IggyByteSize::from(body.len() as u64);
+ partition.consensus().sequencer().set_sequence(1);
+ partition.consensus().set_last_prepare_checksum(checksum);
+ partition.consensus().restore_commit_state(1, 1);
+ partition.log.index_writers_mut()[0] = Some(Rc::new(
+ partitions::IggyIndexWriter::new(
+ "/dev/full",
+ Rc::new(std::sync::atomic::AtomicU64::new(0)),
+ false,
+ false,
+ )
+ .await
+ .unwrap(),
+ ));
+ persistence.request_checkpoint();
+ assert!(partition.needs_persistence_checkpoint());
+ assert!(partition.fatal().is_none());
+ shard.plane.partitions().insert(namespace, partition);
+
+ let fault = shard
+ .tick_partitions(&mut Vec::new())
+ .await
+ .expect("the checkpoint fault must be returned in its originating sweep");
+ assert_eq!(fault.namespace_raw, namespace.inner());
+ assert_eq!(fault.op, 1);
+ assert_eq!(fault.operation, Operation::SendMessages);
+ assert_eq!(persistence.checkpoint_op(), 0);
+ }
+
#[compio::test]
async fn given_invalid_partition_writes_when_resolving_should_preserve_offset_error_codes() {
const VSR_CLIENT: u128 = 1;
@@ -1715,8 +1839,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
@@ -1842,8 +1965,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
@@ -1908,8 +2030,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
diff --git a/core/server/src/dispatch/reads.rs b/core/server/src/dispatch/reads.rs
index bcbb7de..5a459de 100644
--- a/core/server/src/dispatch/reads.rs
+++ b/core/server/src/dispatch/reads.rs
@@ -40,7 +40,7 @@
use crate::snapshot;
use crate::wire::request_body;
use bytes::Bytes;
-use configs::server::{ServerConfig, ServerSystemConfig};
+use configs::server::ServerConfig;
use consensus::MetadataHandle;
use futures::future::{Either, select};
use iggy_binary_protocol::PrepareHeader;
@@ -341,7 +341,7 @@
pub(in crate::dispatch) async fn handle_non_replicated_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
- system_config: &Arc<ServerSystemConfig>,
+ server_config: &Arc<ServerConfig>,
transport_client_id: u128,
request: Message<RoutedRequestHeader>,
// Acting user, peer address and read-your-writes floor for the read gates
@@ -483,7 +483,7 @@
.await;
}
GET_SNAPSHOT_FILE_CODE => {
- handle_get_snapshot(shard, system_config, transport_client_id, &request, user_id).await;
+ handle_get_snapshot(shard, server_config, transport_client_id, &request, user_id).await;
}
POLL_MESSAGES_CODE => {
handle_poll_messages(shard, transport_client_id, &request, user_id).await;
@@ -634,7 +634,7 @@
#[allow(clippy::future_not_send)]
async fn handle_get_snapshot<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
- system_config: &Arc<ServerSystemConfig>,
+ server_config: &Arc<ServerConfig>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
user_id: Option<u32>,
@@ -651,7 +651,7 @@
}
let result = match decode_get_snapshot(request_body(request)) {
Ok((compression, snapshot_types)) => {
- snapshot::collect(Arc::clone(system_config), compression, snapshot_types).await
+ snapshot::collect(Arc::clone(server_config), compression, snapshot_types).await
}
Err(error) => Err(error),
};
diff --git a/core/server/src/dispatch/session_ops.rs b/core/server/src/dispatch/session_ops.rs
index 0dde386..cb79930 100644
--- a/core/server/src/dispatch/session_ops.rs
+++ b/core/server/src/dispatch/session_ops.rs
@@ -1471,8 +1471,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
diff --git a/core/server/src/dispatch/test_support.rs b/core/server/src/dispatch/test_support.rs
index bbfded1..f00b64b 100644
--- a/core/server/src/dispatch/test_support.rs
+++ b/core/server/src/dispatch/test_support.rs
@@ -183,8 +183,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
diff --git a/core/server/src/http.rs b/core/server/src/http.rs
index 8748ea8..e7ecdc1 100644
--- a/core/server/src/http.rs
+++ b/core/server/src/http.rs
@@ -58,7 +58,7 @@
use compio::net::TcpListener;
use configs::cluster::{ClusterConfig, http_forwarding_key_material};
use configs::http::{HttpConfig, HttpCorsConfig};
-use configs::server::ServerSystemConfig;
+use configs::server::ServerConfig;
use iggy_common::IggyError;
use message_bus::client_listener;
use send_wrapper::SendWrapper;
@@ -203,7 +203,7 @@
http_config: &HttpConfig,
clients_table_max: usize,
max_tokens_per_user: u32,
- system_config: Arc<ServerSystemConfig>,
+ server_config: Arc<ServerConfig>,
roster: Rc<ClusterRoster>,
shard_metrics_all: &[shard::metrics::ShardMetrics],
) -> Result<(), ServerError> {
@@ -223,7 +223,7 @@
let state: HttpState = SendWrapper::new(Rc::new(HttpInner {
shard: Rc::clone(shard),
jwt,
- system_config,
+ server_config,
sessions: RefCell::new(HashMap::new()),
registrations: RegistrationBarrier::default(),
roster,
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index cdd8f63..be50736 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -129,7 +129,7 @@
use crate::http::metrics::gauge_value;
use crate::http::reads::{
authorize_data_plane, gate_local_read, read_local, resolve_gate_stream, resolve_gate_topic,
- resolve_gate_topic_ids, resolve_gate_user,
+ resolve_gate_topic_ids, resolve_gate_user, topic_durability,
};
use crate::http::reply::{
committed_payload, decode_consumer_group_details, decode_raw_pat_token, decode_stream_details,
@@ -165,12 +165,11 @@
const HTTP_READ_CLIENT_ID: u128 = 0;
/// Response header attesting what durability a produce response proves:
-/// [`DURABILITY_REPLICATED_MEMORY`] after an awaited quorum commit,
-/// [`DURABILITY_NONE`] for a `?ack=none` fire-and-forget.
+/// The completed topic policy after an awaited quorum commit. If namespace
+/// replacement prevents attesting its incarnation, report the proven quorum
+/// guarantee. [`DURABILITY_NONE`] means `?ack=none` dispatch acceptance.
const DURABILITY_HEADER: HeaderName = HeaderName::from_static("iggy-durability");
-const DURABILITY_REPLICATED_MEMORY: &str = "replicated-memory";
-
const DURABILITY_NONE: &str = "none";
/// `{user_id}` segment alias resolving to the caller in `GET /users/{user_id}`.
@@ -697,7 +696,7 @@
))
.await?;
let archive = snapshot::collect(
- Arc::clone(&state.system_config),
+ Arc::clone(&state.server_config),
command.compression,
command.snapshot_types,
)
@@ -941,7 +940,27 @@
let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
// Rejects empty/oversized name and partitions_count > MAX.
command.validate().map_err(WriteError::Rejected)?;
+ let durability = command
+ .options
+ .get(iggy_common::topic_option_keys::DURABILITY)
+ .map(|value| value.parse())
+ .transpose()
+ .map_err(|_| WriteError::Rejected(IggyError::InvalidOptionValue("durability".to_string())))?
+ .unwrap_or_default();
+ let consumer_offset_durability = command
+ .options
+ .get(iggy_common::topic_option_keys::CONSUMER_OFFSET_DURABILITY)
+ .map(|value| value.parse())
+ .transpose()
+ .map_err(|_| {
+ WriteError::Rejected(IggyError::InvalidOptionValue(
+ "consumer_offset_durability".to_string(),
+ ))
+ })?
+ .unwrap_or_default();
let options = TopicCreateOptions {
+ durability,
+ consumer_offset_durability,
partitions_count: Some(command.partitions_count),
compression_algorithm: (command.compression_algorithm != CompressionAlgorithm::default())
.then_some(command.compression_algorithm),
@@ -952,7 +971,9 @@
raw: command.options,
..TopicCreateOptions::default()
};
- let wire_options = options.to_wire().map_err(WriteError::Rejected)?;
+ let wire_options = options
+ .to_explicit_wire(|key| options.raw.contains_key(key))
+ .map_err(WriteError::Rejected)?;
// Re-parse the encoded block so `--set`-style raw string entries get the
// same typed pre-consensus checks as native fields; unknown keys deny
// here with the key name.
@@ -1357,6 +1378,7 @@
Some(
PartitionReadReply::ConsumerOffset { stored: None, .. } | PartitionReadReply::NotFound,
) => Err(ReadError::NotFound),
+ Some(PartitionReadReply::Rejected(error)) => Err(ReadError::Rejected(error)),
Some(_) => Err(ReadError::Rejected(IggyError::InvalidCommand)),
None => Err(ReadError::Timeout),
}
@@ -1371,7 +1393,7 @@
/// consensus (at-least-once, no dedup, no session gate - concurrent produces
/// on one credential are legal), and the committed reply comes back through
/// the session's in-process reply slot rather than a submit return value.
-/// The default answers 201 + `Iggy-Durability: replicated-memory` only
+/// The default answers 201 with the completed message durability only
/// after the quorum commit, with the commit's per-partition confirmations as
/// the body; `?ack=none` answers 202 + `Iggy-Durability: none` immediately
/// after dispatch and can carry no confirmation, having awaited none.
@@ -1401,6 +1423,13 @@
.map_err(PartitionWriteError::Rejected)?;
// Rejects an oversized partitioning key and an empty or oversized batch.
command.validate().map_err(PartitionWriteError::Rejected)?;
+ let policy = topic_durability(&state, &stream_id, &topic_id);
+ // Names can be reused while the session gate is held by another request.
+ let (stream_id, topic_id) = policy
+ .map(super::reads::TopicDurability::identifiers)
+ .transpose()
+ .map_err(PartitionWriteError::Rejected)?
+ .unwrap_or((stream_id, topic_id));
let body = encode_send_messages(&stream_id, &topic_id, &command)
.map_err(PartitionWriteError::Rejected)?;
match query.ack {
@@ -1412,10 +1441,10 @@
&body,
))
.await?;
- let durability = [(
- DURABILITY_HEADER,
- HeaderValue::from_static(DURABILITY_REPLICATED_MEMORY),
- )];
+ let policy = policy.map_or(iggy_common::Durability::Replicated, |policy| {
+ policy.confirmed_policy(&state)
+ });
+ let durability = [(DURABILITY_HEADER, HeaderValue::from_static(policy.into()))];
// An unreadable confirmation still answers 201: the batch committed,
// only its offsets did not survive the reply.
let confirmations = send_confirmations(&reply, &header)
diff --git a/core/server/src/http/reads.rs b/core/server/src/http/reads.rs
index bcb387a..81f2873 100644
--- a/core/server/src/http/reads.rs
+++ b/core/server/src/http/reads.rs
@@ -404,6 +404,87 @@
.authorize(|permissioner| rule(permissioner, user_id, stream_id, topic_id))
}
+static DURABILITY_KEY: std::sync::LazyLock<iggy_common::HeaderKey> =
+ std::sync::LazyLock::new(|| "durability".parse().expect("catalog key is valid"));
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(in crate::http) struct TopicDurability {
+ stream_id: usize,
+ topic_id: usize,
+ created_revision: u64,
+ pub durability: iggy_common::Durability,
+}
+
+impl TopicDurability {
+ pub fn identifiers(self) -> Result<(Identifier, Identifier), IggyError> {
+ let stream_id = u32::try_from(self.stream_id).map_err(|_| IggyError::InvalidIdentifier)?;
+ let topic_id = u32::try_from(self.topic_id).map_err(|_| IggyError::InvalidIdentifier)?;
+ Ok((
+ Identifier::numeric(stream_id)?,
+ Identifier::numeric(topic_id)?,
+ ))
+ }
+
+ pub fn confirmed_policy(self, state: &HttpInner) -> iggy_common::Durability {
+ state
+ .shard
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .read(|inner| self.confirmed_policy_in(inner))
+ }
+
+ fn confirmed_policy_in(
+ self,
+ inner: &metadata::stm::stream::StreamsInner,
+ ) -> iggy_common::Durability {
+ let unchanged = inner
+ .items
+ .get(self.stream_id)
+ .and_then(|stream| stream.topics.get(self.topic_id))
+ .and_then(|topic| topic.partitions.first())
+ .is_some_and(|partition| partition.created_revision == self.created_revision);
+ if unchanged {
+ self.durability
+ } else {
+ iggy_common::Durability::Replicated
+ }
+ }
+}
+
+pub(in crate::http) fn topic_durability(
+ state: &HttpInner,
+ stream: &Identifier,
+ topic: &Identifier,
+) -> Option<TopicDurability> {
+ let stream = identifier_to_wire(stream).ok()?;
+ let topic = identifier_to_wire(topic).ok()?;
+ state
+ .shard
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .read(|inner| {
+ let stream_id = resolve_stream_id(inner, &stream)?;
+ let topic_id = resolve_topic_id(inner, stream_id, &topic)?;
+ let topic = inner.items.get(stream_id)?.topics.get(topic_id)?;
+ let created_revision = topic.partitions.first()?.created_revision;
+ Some(TopicDurability {
+ stream_id,
+ topic_id,
+ created_revision,
+ durability: topic
+ .options
+ .get(&DURABILITY_KEY)
+ .and_then(|option| std::str::from_utf8(option.value.as_bytes()).ok())
+ .and_then(|value| value.parse().ok())
+ .unwrap_or_default(),
+ })
+ })
+}
+
#[cfg(test)]
mod tests {
use super::{
@@ -411,15 +492,155 @@
read_needs_metadata_frontier,
};
use crate::http::state::MetadataWatermarks;
+ use crate::http::wire::encode_send_messages;
+ use crate::responses::{resolve_stream_id, resolve_topic_id};
use iggy_binary_protocol::codes::{
DESCRIBE_OPTIONS_CODE, GET_CONSUMER_GROUPS_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
GET_STATS_CODE, GET_STREAM_CODE, GET_STREAMS_CODE, GET_TOPIC_CODE, GET_TOPICS_CODE,
GET_USER_CODE, GET_USERS_CODE,
};
+ use iggy_binary_protocol::requests::messages::SendMessagesHeader;
+ use iggy_binary_protocol::{WireDecode, WireIdentifier};
use metadata::AppliedFrontier;
use std::future::pending;
use std::sync::Arc;
+ #[test]
+ fn produce_routing_keeps_the_attested_topic_when_names_are_reused() {
+ for rename_stream in [false, true] {
+ let mut inner = metadata::stm::stream::StreamsInner::default();
+ let mut stream = metadata::stm::stream::Stream::default();
+ let topic_id = stream.topics.insert(metadata::stm::stream::Topic {
+ name: "orders".into(),
+ partitions: vec![metadata::stm::stream::Partition::new(
+ 0,
+ 1,
+ iggy_common::IggyTimestamp::default(),
+ 3,
+ 0,
+ )],
+ ..Default::default()
+ });
+ stream.topic_index.insert("orders".into(), topic_id);
+ let stream_id = inner.items.insert(stream);
+ inner.index.insert("events".into(), stream_id);
+ let original = super::TopicDurability {
+ stream_id,
+ topic_id,
+ created_revision: 3,
+ durability: iggy_common::Durability::Persisted,
+ };
+ let (stream, topic) = original.identifiers().unwrap();
+ let message = iggy_common::IggyMessage::builder()
+ .payload(bytes::Bytes::from_static(b"payload"))
+ .build()
+ .unwrap();
+ let command = iggy_common::SendMessages {
+ batch: iggy_common::IggyMessagesBatch::from(&vec![message]),
+ ..Default::default()
+ };
+ let body = encode_send_messages(&stream, &topic, &command).unwrap();
+ let metadata_length = u32::from_le_bytes(body[..4].try_into().unwrap()) as usize;
+ let (request, _) = SendMessagesHeader::decode(&body[4..4 + metadata_length]).unwrap();
+
+ let stream = inner.items.get_mut(stream_id).unwrap();
+ stream.topics.get_mut(topic_id).unwrap().name = "renamed".into();
+ stream.topic_index.insert("renamed".into(), topic_id);
+ let replacement_topic = stream
+ .topics
+ .insert(metadata::stm::stream::Topic::default());
+ stream
+ .topic_index
+ .insert("orders".into(), replacement_topic);
+ assert_eq!(
+ resolve_topic_id(&inner, stream_id, &WireIdentifier::named("orders").unwrap()),
+ Some(replacement_topic)
+ );
+ if rename_stream {
+ inner.items.get_mut(stream_id).unwrap().name = "renamed-stream".into();
+ inner.index.insert("renamed-stream".into(), stream_id);
+ let replacement_stream =
+ inner.items.insert(metadata::stm::stream::Stream::default());
+ inner.index.insert("events".into(), replacement_stream);
+ assert_eq!(
+ resolve_stream_id(&inner, &WireIdentifier::named("events").unwrap()),
+ Some(replacement_stream)
+ );
+ }
+
+ assert_eq!(
+ resolve_stream_id(&inner, &request.stream_id),
+ Some(stream_id)
+ );
+ assert_eq!(
+ resolve_topic_id(&inner, stream_id, &request.topic_id),
+ Some(topic_id)
+ );
+ assert_eq!(
+ original.confirmed_policy_in(&inner),
+ iggy_common::Durability::Persisted
+ );
+ }
+ }
+
+ #[test]
+ fn completed_produce_checks_the_stored_topic_incarnation() {
+ let mut inner = metadata::stm::stream::StreamsInner::default();
+ let mut stream = metadata::stm::stream::Stream::default();
+ let topic = metadata::stm::stream::Topic {
+ partitions: vec![metadata::stm::stream::Partition::new(
+ 0,
+ 1,
+ iggy_common::IggyTimestamp::default(),
+ 3,
+ 0,
+ )],
+ ..Default::default()
+ };
+ let topic_id = stream.topics.insert(topic);
+ let stream_id = inner.items.insert(stream);
+ let original = super::TopicDurability {
+ stream_id,
+ topic_id,
+ created_revision: 3,
+ durability: iggy_common::Durability::Persisted,
+ };
+ assert_eq!(
+ original.confirmed_policy_in(&inner),
+ iggy_common::Durability::Persisted
+ );
+ inner
+ .items
+ .get_mut(stream_id)
+ .unwrap()
+ .topics
+ .get_mut(topic_id)
+ .unwrap()
+ .name = "renamed".into();
+ assert_eq!(
+ original.confirmed_policy_in(&inner),
+ iggy_common::Durability::Persisted
+ );
+ inner
+ .items
+ .get_mut(stream_id)
+ .unwrap()
+ .topics
+ .get_mut(topic_id)
+ .unwrap()
+ .partitions[0]
+ .created_revision = 4;
+ assert_eq!(
+ original.confirmed_policy_in(&inner),
+ iggy_common::Durability::Replicated
+ );
+ inner.items.remove(stream_id);
+ assert_eq!(
+ original.confirmed_policy_in(&inner),
+ iggy_common::Durability::Replicated
+ );
+ }
+
/// Root's user id, the caller every fixture below writes and reads as.
const USER: u32 = 0;
diff --git a/core/server/src/http/state.rs b/core/server/src/http/state.rs
index c55acfc..5c64f4c 100644
--- a/core/server/src/http/state.rs
+++ b/core/server/src/http/state.rs
@@ -27,7 +27,7 @@
use axum::http::{HeaderName, HeaderValue};
use axum::response::Response;
-use configs::server::ServerSystemConfig;
+use configs::server::ServerConfig;
use consensus::{MetadataHandle, VsrConsensus};
use futures::channel::oneshot;
use iggy_common::{ClusterMetadata, IggyTimestamp};
@@ -160,7 +160,7 @@
/// Read-only server config for the snapshot collector (log directory +
/// runtime config paths); the shard does not expose config on the read
/// path.
- pub(in crate::http) system_config: Arc<ServerSystemConfig>,
+ pub(in crate::http) server_config: Arc<ServerConfig>,
/// Per-credential VSR sessions keyed by JWT `jti` / PAT hash. `RefCell` is
/// sound here - shard 0 is single-threaded and the `SendWrapper` state
/// bridge tolerates the `!Sync` interior - but the guard must never be held
diff --git a/core/server/src/main.rs b/core/server/src/main.rs
index 14d5592..8459a71 100644
--- a/core/server/src/main.rs
+++ b/core/server/src/main.rs
@@ -75,8 +75,7 @@
let bootstrap_result: Result<ServerConfig, ServerError> = bootstrap_runtime.block_on(async {
let config = load_config().await?;
prepare_runtime_dirs(&config, &mut logging, args.fresh).await?;
- let memory_pool_settings =
- server_common::MemoryPoolSettings::from(&config.system.memory_pool);
+ let memory_pool_settings = server_common::MemoryPoolSettings::from(&config.memory_pool);
server_common::MemoryPool::init_pool(&memory_pool_settings);
Ok(config)
diff --git a/core/server/src/partition_helpers.rs b/core/server/src/partition_helpers.rs
index af6e190..cebe040 100644
--- a/core/server/src/partition_helpers.rs
+++ b/core/server/src/partition_helpers.rs
@@ -32,7 +32,9 @@
use crate::offset_recovery::{
RecoveredOffsets, load_consumer_group_offsets, load_consumer_offsets,
};
-use crate::segment_recovery::{RecoveredSegment, load_persisted_segments};
+use crate::segment_recovery::{
+ RecoveredSegment, load_persisted_segments, load_persisted_segments_with_checkpoint,
+};
use crate::server_error::{PartitionRecoveryRefusal, ServerError};
use crate::shell::consensus_timers;
use compio::fs::create_dir_all;
@@ -44,12 +46,15 @@
ConsumerGroupOffsets, ConsumerKind, ConsumerOffsets, IggyByteSize, IggyError, IggyTimestamp,
PartitionStats, TopicRuntimeOptions,
};
+use journal::durable_storage::{DiskStorage, DurableStorage};
+use journal::partition_journal::SegmentPosition;
use journal::superblock::{PingPongSuperblock, SuperblockContents};
use message_bus::IggyMessageBus;
use metadata::stm::stream::Partition;
use metadata::{IdentityField, ReplicaIdentity};
use partitions::{
- IggyIndexWriter, IggyPartition, IggyPartitions, MessagesWriter, PartitionsConfig, Segment,
+ IggyIndexWriter, IggyPartition, IggyPartitions, MessagesWriter, PartitionPersistence,
+ PartitionsConfig, Segment,
};
use server_common::SegmentStorage;
use server_common::fs_utils::remove_dir_all;
@@ -77,9 +82,7 @@
partition_id: usize,
config: &ServerConfig,
) -> Result<(), IggyError> {
- let partition_path = config
- .system
- .get_partition_path(stream_id, topic_id, partition_id);
+ let partition_path = config.get_partition_path(stream_id, topic_id, partition_id);
if !Path::new(&partition_path).exists() && create_dir_all(&partition_path).await.is_err() {
return Err(IggyError::CannotCreatePartitionDirectory(
partition_id,
@@ -88,9 +91,7 @@
));
}
- let offset_path = config
- .system
- .get_offsets_path(stream_id, topic_id, partition_id);
+ let offset_path = config.get_offsets_path(stream_id, topic_id, partition_id);
if !Path::new(&offset_path).exists() && create_dir_all(&offset_path).await.is_err() {
error!(
stream_id,
@@ -103,10 +104,7 @@
));
}
- let consumer_offset_path =
- config
- .system
- .get_consumer_offsets_path(stream_id, topic_id, partition_id);
+ let consumer_offset_path = config.get_consumer_offsets_path(stream_id, topic_id, partition_id);
if !Path::new(&consumer_offset_path).exists()
&& create_dir_all(&consumer_offset_path).await.is_err()
{
@@ -122,9 +120,7 @@
}
let consumer_group_offsets_path =
- config
- .system
- .get_consumer_group_offsets_path(stream_id, topic_id, partition_id);
+ config.get_consumer_group_offsets_path(stream_id, topic_id, partition_id);
if !Path::new(&consumer_group_offsets_path).exists()
&& create_dir_all(&consumer_group_offsets_path).await.is_err()
{
@@ -167,14 +163,9 @@
let stream_id = namespace.stream_id();
let topic_id = namespace.topic_id();
let partition_id = namespace.partition_id();
- let consumer_offsets_path =
- config
- .system
- .get_consumer_offsets_path(stream_id, topic_id, partition_id);
+ let consumer_offsets_path = config.get_consumer_offsets_path(stream_id, topic_id, partition_id);
let consumer_group_offsets_path =
- config
- .system
- .get_consumer_group_offsets_path(stream_id, topic_id, partition_id);
+ config.get_consumer_group_offsets_path(stream_id, topic_id, partition_id);
// The bound is the offset space this replica could have MINTED, not the data
// it can still serve. A boot re-anchor leaves the append point a lease block
// above the recovered chain, so on the restart after a crash that took
@@ -266,7 +257,7 @@
}
}
- // Offset files have their own knob, not the topic's `enforce_fsync`: that
+ // Offset files have their own knob, not the topic's `persisted`: that
// one gates message and index writes, and syncing a 16-byte cursor on every
// commit costs milliseconds per commit for a file whose loss is a redelivery.
partition.configure_consumer_offset_storage(
@@ -274,7 +265,6 @@
consumer_group_offsets_path.clone(),
consumer_offsets,
consumer_group_offsets,
- config.partition.consumer_offset_enforce_fsync,
);
for consumer_id in recovered_consumers.stranded_ids {
if partition.seed_stranded_consumer_offset(ConsumerKind::Consumer, consumer_id) {
@@ -376,13 +366,15 @@
pub async fn ensure_initial_segment(
partition: &mut IggyPartition<Rc<IggyMessageBus>>,
config: &ServerConfig,
- stream_id: usize,
- topic_id: usize,
- partition_id: usize,
+ namespace: IggyNamespace,
+ wal_owned_messages: bool,
) -> Result<(), ServerError> {
if partition.log.has_segments() {
return Ok(());
}
+ let stream_id = namespace.stream_id();
+ let topic_id = namespace.topic_id();
+ let partition_id = namespace.partition_id();
// At the RESTORED FRONTIER, not always 0: after a crash inside the install's
// swap window the chain is empty while the recorded frontier is N, and a
@@ -392,39 +384,37 @@
// offering peers a segment that claims `[0..N]`.
let start_offset = partition.mint_frontier();
let messages_path =
- config
- .system
- .get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
- let index_path = config
- .system
- .get_index_path(stream_id, topic_id, partition_id, start_offset);
+ config.get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
+ let index_path = config.get_index_path(stream_id, topic_id, partition_id, start_offset);
let runtime = partition.runtime_options();
- let segment_size = runtime
- .segment_size
- .unwrap_or_else(|| IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE));
- let enforce_fsync = runtime
- .enforce_fsync
- .unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC);
+ let segment_size = runtime.effective_segment_size();
+ let persisted = runtime.durability.is_persisted();
let preallocate_segments = runtime
.preallocate_segments
.unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS);
- // `file_exists = false` TRUNCATES both files, which is load-bearing here: a
- // fenced-and-rebuilt partition (or one whose quarantine failed) can reach
- // this with a stale `.index` at offset 0 on disk. The `partitions`-side
- // writers with the same names do NOT truncate, so opening them directly
- // instead would read index entries from a previous generation.
- let storage = SegmentStorage::new(&messages_path, &index_path, 0, 0, false)
+ // Recreate stale indexes, but preserve any physical tail retained by the WAL.
+ let storage = if wal_owned_messages {
+ SegmentStorage::with_read_only_messages(
+ &messages_path,
+ &index_path,
+ 0,
+ false,
+ preallocate_segments.then_some(segment_size.as_bytes_u64()),
+ )
.await
- .map_err(|source| {
- error!(
- stream_id,
- topic_id,
- partition_id,
- error = %source,
- "failed to create initial segment storage"
- );
- source
- })?;
+ } else {
+ SegmentStorage::new(&messages_path, &index_path, 0, 0, false).await
+ }
+ .map_err(|source| {
+ error!(
+ stream_id,
+ topic_id,
+ partition_id,
+ error = %source,
+ "failed to create initial segment storage"
+ );
+ source
+ })?;
// Share the storage's size counters: they are the write cursors. A private
// counter would let the append position diverge from the segment
// bookkeeping that index entries and poll bounds rely on.
@@ -438,14 +428,14 @@
.as_ref()
.map(|writer| writer.size_counter())
.unwrap_or_default();
- partition.log.add_persisted_segment(
- Segment::new(start_offset, segment_size),
- storage,
+ let messages_writer = if wal_owned_messages {
+ None
+ } else {
Some(Rc::new(
MessagesWriter::new(
&messages_path,
messages_size_counter,
- enforce_fsync,
+ persisted,
false,
preallocate_segments.then_some(segment_size),
)
@@ -461,9 +451,14 @@
);
source
})?,
- )),
+ ))
+ };
+ partition.log.add_persisted_segment(
+ Segment::new(start_offset, segment_size),
+ storage,
+ messages_writer,
Some(Rc::new(
- IggyIndexWriter::new(&index_path, index_size_counter, enforce_fsync, false)
+ IggyIndexWriter::new(&index_path, index_size_counter, persisted, false)
.await
.map_err(|source| {
error!(
@@ -593,6 +588,13 @@
) -> Result<Option<IggyPartition<Rc<IggyMessageBus>>>, ServerError> {
let stream_id = namespace.stream_id();
let topic_id = namespace.topic_id();
+ let directory = config.get_partition_path(stream_id, topic_id, namespace.partition_id());
+ partitions::install_backup::recover(Path::new(&directory))
+ .await
+ .map_err(|source| ServerError::PartitionSuperblockIo {
+ dir: PathBuf::from(&directory),
+ source,
+ })?;
// Heap-pinned: the loader's and the rebuilder's futures side by side
// outgrow clippy's `large_futures` cap, and this runs once per partition.
match Box::pin(load_partition(
@@ -688,7 +690,23 @@
partitions.tombstone(namespace);
return Ok(None);
}
- match partitions::state_transfer::quarantine_segment_files(&partition_dir).await {
+ if replica_count > 1 {
+ partitions::state_transfer::mark_materialization_missing(
+ &partition_dir,
+ partition_metadata.created_revision,
+ )
+ .await
+ .map_err(|error| {
+ error!(%error, "cannot persist missing-materialization fence");
+ ServerError::Iggy(Box::new(IggyError::CannotSyncFile))
+ })?;
+ }
+ match partitions::state_transfer::quarantine_partition_files(
+ &partition_dir,
+ (replica_count > 1).then_some(partition_metadata.created_revision),
+ )
+ .await
+ {
Ok(fenced_dir) => error!(
stream_id,
topic_id,
@@ -768,7 +786,7 @@
}
}
-#[allow(clippy::too_many_arguments)]
+#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn load_partition(
config: &ServerConfig,
partitions_config: &PartitionsConfig,
@@ -787,9 +805,7 @@
// (view, log_view) come from the group's durable superblock when present;
// a present but unverifiable record already refused boot inside
// `open_partition_superblock`.
- let partition_dir = config
- .system
- .get_partition_path(stream_id, topic_id, partition_id);
+ let partition_dir = config.get_partition_path(stream_id, topic_id, partition_id);
let (superblock, recovered_state) = open_partition_superblock(
&partition_dir,
ReplicaIdentity {
@@ -848,8 +864,54 @@
// recovered message timestamp here, or an NTP rewind across a restart could
// regress persisted `base_timestamp`.
- let recovered_segments =
- recover_partition_segments(config, namespace, runtime_options, &stats).await?;
+ let recovered_persistence = if replica_count > 1
+ && (runtime_options.durability.is_persisted()
+ || runtime_options.consumer_offset_durability.is_persisted())
+ {
+ let directory = Path::new(&partition_dir)
+ .join(format!("prepares-{}", partition_metadata.created_revision));
+ Some(
+ PartitionPersistence::open_with_capacity(
+ &directory,
+ namespace.inner(),
+ partition_metadata.created_revision,
+ DiskStorage,
+ config.partition.wal_bytes_max.as_bytes_u64(),
+ runtime_options
+ .preallocate_segments
+ .unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS),
+ )
+ .await
+ .map_err(|source| match source.kind() {
+ std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof => {
+ ServerError::PartitionRecoveryRefused {
+ dir: PathBuf::from(&partition_dir),
+ stream_id: namespace.stream_id(),
+ topic_id: namespace.topic_id(),
+ partition_id: namespace.partition_id(),
+ reason: PartitionRecoveryRefusal::PrepareWal { directory, source },
+ }
+ }
+ _ => ServerError::PartitionPrepareWalIo {
+ dir: directory,
+ source,
+ },
+ })?,
+ )
+ } else {
+ None
+ };
+ let segment_checkpoint = recovered_persistence
+ .as_ref()
+ .and_then(|(persistence, _)| persistence.segment_checkpoint());
+ let recovered_segments = recover_partition_segments(
+ config,
+ namespace,
+ runtime_options,
+ &stats,
+ segment_checkpoint,
+ )
+ .await?;
let mut partition = IggyPartition::new(stats.clone(), consensus);
partition.set_runtime_options(runtime_options);
@@ -880,12 +942,31 @@
.await?;
partition.created_at = partition_metadata.created_at;
- restore_partition_offsets(&mut partition, partitions_config, recovered_state.as_ref()).await?;
+ restore_partition_offsets(
+ &mut partition,
+ partitions_config,
+ recovered_state.as_ref(),
+ segment_checkpoint,
+ )
+ .await?;
let current_offset = partition.offset.load(Ordering::Acquire);
configure_consumer_offsets(&mut partition, config, namespace, current_offset).await?;
- ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
+ ensure_initial_segment(
+ &mut partition,
+ config,
+ namespace,
+ segment_checkpoint.is_some(),
+ )
+ .await?;
+ partition
+ .open_persistence_with_recovered(
+ config.partition.wal_bytes_max.as_bytes_u64(),
+ recovered_persistence,
+ )
+ .await
+ .map_err(|error| ServerError::Iggy(Box::new(error)))?;
Ok(partition)
}
@@ -900,6 +981,7 @@
partition: &mut IggyPartition<Rc<IggyMessageBus>>,
partitions_config: &PartitionsConfig,
recovered_state: Option<&VsrState>,
+ segment_checkpoint: Option<SegmentPosition>,
) -> Result<(), ServerError> {
let sized_end = partition
.log
@@ -929,8 +1011,12 @@
.max()
.map(|start| start.min(durable_frontier))
.filter(|&start| sized_end.is_none() && start > 0);
- let current_offset = sized_end.or_else(|| empty_frontier.map(|start| start - 1));
- partition.recovered_durable_offset = sized_end;
+ let checkpoint_end =
+ segment_checkpoint.and_then(|checkpoint| checkpoint.next_offset.checked_sub(1));
+ let current_offset = sized_end
+ .or_else(|| empty_frontier.map(|start| start - 1))
+ .max(checkpoint_end);
+ partition.recovered_durable_offset = sized_end.max(checkpoint_end);
// The OFFSET COUNTER is restored from that file name (above), but the
// `installed_frontier` CLAIM deliberately is not: the claim says "everything
// below me is represented here", and `converge_to_empty_after_failed_install`
@@ -972,9 +1058,9 @@
/// Recover this partition's persisted segment chain, stamping each segment
/// with the topic's effective segment size (the per-topic value when the
-/// topic was created with one, else the shard-wide configured size).
+/// topic was created with one, else the shared segment-size default).
///
-/// The topic's effective `enforce_fsync` goes in for the same reason: it is
+/// The topic's effective `persisted` goes in for the same reason: it is
/// what tells recovery whether a durable index entry the log cannot back is a
/// benign torn index or previously durable data the log lost.
async fn recover_partition_segments(
@@ -982,28 +1068,36 @@
namespace: IggyNamespace,
runtime_options: TopicRuntimeOptions,
stats: &PartitionStats,
+ checkpoint: Option<SegmentPosition>,
) -> Result<Vec<RecoveredSegment>, ServerError> {
let stream_id = namespace.stream_id();
let topic_id = namespace.topic_id();
let partition_id = namespace.partition_id();
- let segment_size = runtime_options
- .segment_size
- .unwrap_or_else(|| IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE));
- let enforce_fsync = runtime_options
- .enforce_fsync
- .unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC);
- load_persisted_segments(config, namespace, segment_size, enforce_fsync, stats)
+ let segment_size = runtime_options.effective_segment_size();
+ let persisted = runtime_options.durability.is_persisted();
+ let recovered = if checkpoint.is_some() {
+ load_persisted_segments_with_checkpoint(
+ config,
+ namespace,
+ segment_size,
+ persisted,
+ stats,
+ checkpoint,
+ )
.await
- .map_err(|source| {
- error!(
- stream_id,
- topic_id,
- partition_id,
- error = %source,
- "failed to load partition log during server bootstrap"
- );
- source
- })
+ } else {
+ load_persisted_segments(config, namespace, segment_size, persisted, stats).await
+ };
+ recovered.map_err(|source| {
+ error!(
+ stream_id,
+ topic_id,
+ partition_id,
+ error = %source,
+ "failed to load partition log during server bootstrap"
+ );
+ source
+ })
}
/// Reopen writers over a recovered segment chain.
@@ -1019,16 +1113,12 @@
recovered_segments: Vec<RecoveredSegment>,
) -> Result<(), ServerError> {
// The partition's own resolved knobs, not the shard-wide config: a topic
- // created with `enforce_fsync` or a per-topic `segment_size` must get them
+ // created with `persisted` or a per-topic `segment_size` must get them
// on the writers reopened over its recovered chain too, or a restart would
// silently drop back to the node defaults.
let runtime = partition.runtime_options();
- let enforce_fsync = runtime
- .enforce_fsync
- .unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC);
- let segment_size = runtime
- .segment_size
- .unwrap_or_else(|| IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE));
+ let persisted = runtime.durability.is_persisted();
+ let segment_size = runtime.effective_segment_size();
let preallocate_segments = runtime
.preallocate_segments
.unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS);
@@ -1040,6 +1130,21 @@
if let Some(active_index) = partition.log.segments().len().checked_sub(1) {
let storage = &partition.log.storages()[active_index];
+ if storage.messages_writer.is_none()
+ && let (Some(index_reader), Some(index_writer)) =
+ (&storage.index_reader, &storage.index_writer)
+ {
+ partition.log.index_writers_mut()[active_index] = Some(Rc::new(
+ IggyIndexWriter::new(
+ &index_reader.path(),
+ index_writer.size_counter(),
+ persisted,
+ true,
+ )
+ .await?,
+ ));
+ return Ok(());
+ }
if let (
Some(messages_reader),
Some(index_reader),
@@ -1062,7 +1167,7 @@
MessagesWriter::new(
&messages_reader.path(),
messages_size_counter,
- enforce_fsync,
+ persisted,
true,
preallocate_segments.then_some(segment_size),
)
@@ -1087,7 +1192,7 @@
})?,
));
partition.log.index_writers_mut()[active_index] = Some(Rc::new(
- IggyIndexWriter::new(&index_path, index_size_counter, enforce_fsync, true)
+ IggyIndexWriter::new(&index_path, index_size_counter, persisted, true)
.await
.map_err(|source| {
error!(
@@ -1207,12 +1312,7 @@
// empty -- committed-but-unflushed data dies with the journal), while a
// genuinely fresh create finds nothing.
let restarted = replica_count > 1
- && std::fs::metadata(
- config
- .system
- .get_partition_path(stream_id, topic_id, partition_id),
- )
- .is_ok();
+ && std::fs::metadata(config.get_partition_path(stream_id, topic_id, partition_id)).is_ok();
create_partition_file_hierarchy(stream_id, topic_id, partition_id, config)
.await
.map_err(|source| {
@@ -1226,13 +1326,21 @@
source
})?;
+ if runtime_options.durability.is_persisted()
+ || runtime_options.consumer_offset_durability.is_persisted()
+ {
+ persist_partition_hierarchy(
+ &config.get_partition_path(stream_id, topic_id, partition_id),
+ &config.get_system_path(),
+ )
+ .await?;
+ }
+
// The hierarchy create above guarantees the directory exists; recover this
// group's durable (view, log_view) before choosing how to join, so a
// restart materialization resumes from the view it last recorded instead
// of re-entering an older one.
- let partition_dir = config
- .system
- .get_partition_path(stream_id, topic_id, partition_id);
+ let partition_dir = config.get_partition_path(stream_id, topic_id, partition_id);
let (superblock, recovered_state) = open_partition_superblock(
&partition_dir,
ReplicaIdentity {
@@ -1350,7 +1458,7 @@
let current_offset = partition.offset.load(Ordering::Acquire);
configure_consumer_offsets(&mut partition, config, namespace, current_offset).await?;
- ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
+ ensure_initial_segment(&mut partition, config, namespace, false).await?;
// Claim the first offset-reservation block HERE so no send ever pays the
// create, write, file fsync, rename and directory fsync of a first claim
@@ -1399,9 +1507,38 @@
});
}
+ partition
+ .open_persistence_with_capacity(config.partition.wal_bytes_max.as_bytes_u64())
+ .await
+ .map_err(|error| ServerError::Iggy(Box::new(error)))?;
Ok(partition)
}
+async fn persist_partition_hierarchy(
+ partition_path: &str,
+ data_root: &str,
+) -> Result<(), ServerError> {
+ let root = Path::new(data_root);
+ let mut current = Path::new(partition_path);
+ loop {
+ DiskStorage
+ .sync_directory(current)
+ .await
+ .map_err(|_| ServerError::Iggy(Box::new(IggyError::CannotSyncFile)))?;
+ if current == root {
+ break;
+ }
+ let Some(parent) = current
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ else {
+ break;
+ };
+ current = parent;
+ }
+ Ok(())
+}
+
/// Recursive delete of partition root. Idempotent: `NotFound` is treated
/// as success so a prior crashed pass cannot arm perpetual backoff.
///
@@ -1415,9 +1552,7 @@
partition_id: usize,
config: &ServerConfig,
) -> Result<(), IggyError> {
- let partition_path = config
- .system
- .get_partition_path(stream_id, topic_id, partition_id);
+ let partition_path = config.get_partition_path(stream_id, topic_id, partition_id);
match remove_dir_all(&partition_path).await {
Ok(()) => {
tracing::info!(
@@ -1461,15 +1596,193 @@
#[cfg(test)]
mod tests {
use super::*;
- use configs::server::ServerSystemConfig;
+ use bytes::Bytes;
+ use configs::server::ServerConfig;
+ use iggy_binary_protocol::batch::BATCH_HEADER_SIZE;
+ use iggy_binary_protocol::{Command, Operation, PrepareHeader};
+ use journal::DurableAppend;
use journal::superblock::SuperblockStore;
use partitions::PartitionPathLayout;
+ use server_common::Message;
+ use server_common::send_messages::{
+ IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
+ };
use server_common::sharding::ShardId;
const CLUSTER: u128 = 7;
const REPLICA: u8 = 1;
const REPLICAS: u8 = 3;
+ #[compio::test]
+ async fn loading_after_retention_preserves_the_wal_owned_tail_when_the_logical_chain_is_empty()
+ {
+ let root = tempfile::tempdir().unwrap();
+ let config = solo_config(&root);
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let mut messages = IggyMessages::with_capacity(1);
+ messages.push(IggyMessage {
+ header: IggyMessageHeader::default(),
+ payload: Bytes::from_static(b"retained-tail"),
+ user_headers: None,
+ });
+ let mut batch = SendMessagesOwned::from_messages(namespace, &messages).unwrap();
+ let segment_size = IggyByteSize::from(batch.header.total_size() as u64);
+ let runtime = TopicRuntimeOptions {
+ durability: iggy_common::Durability::Persisted,
+ segment_size: Some(segment_size),
+ preallocate_segments: Some(false),
+ ..Default::default()
+ };
+ drop(
+ build_partition_fresh(
+ &config,
+ namespace,
+ Arc::new(PartitionStats::default()),
+ 0,
+ runtime,
+ CLUSTER,
+ REPLICA,
+ REPLICAS,
+ 0,
+ Rc::new(IggyMessageBus::new(0)),
+ )
+ .await
+ .unwrap(),
+ );
+ let wal = Path::new(&config.get_partition_path(1, 1, 0)).join("prepares-0");
+ let mut journal = journal::PartitionPrepareJournal::open(&wal, namespace.inner(), 0)
+ .await
+ .unwrap();
+ let mut parent = 0;
+ let mut tail_body = Vec::new();
+ for offset in 0..2 {
+ batch.header.base_offset = offset;
+ batch.header.batch_checksum = batch.header.checksum_for_blob(&batch.blob);
+ let total = size_of::<PrepareHeader>() + batch.header.total_size();
+ let mut prepare = Message::<PrepareHeader>::new(total);
+ let body = &mut prepare.as_mut_slice()[size_of::<PrepareHeader>()..];
+ batch.header.encode_into(body);
+ body[BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ tail_body = body.to_vec();
+ let prepare = prepare.transmute_header(|_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.cluster = CLUSTER;
+ header.group = namespace.inner();
+ header.op = offset + 1;
+ header.parent = parent;
+ header.size = u32::try_from(total).unwrap();
+ header.checksum = header.identity_checksum();
+ });
+ parent = prepare.header().checksum;
+ journal.append(prepare.into_frozen()).await.unwrap();
+ }
+ journal.checkpoint(1).await.unwrap();
+ drop(journal);
+ std::fs::remove_file(config.get_messages_file_path(1, 1, 0, 0)).unwrap();
+ std::fs::remove_file(config.get_index_path(1, 1, 0, 0)).unwrap();
+ let partitions = solo_partitions();
+ let partition = load_partition(
+ &config,
+ partitions.config(),
+ namespace,
+ Arc::new(PartitionStats::default()),
+ &Partition::new(0, namespace.inner(), IggyTimestamp::now(), 0, 0),
+ runtime,
+ CLUSTER,
+ REPLICA,
+ REPLICAS,
+ Rc::new(IggyMessageBus::new(0)),
+ )
+ .await
+ .unwrap();
+ assert_eq!(partition.log.active_segment().start_offset, 1);
+ assert_eq!(partition.log.active_segment().size.as_bytes_u64(), 0);
+ assert!(partition.log.messages_writers().last().unwrap().is_none());
+ assert_eq!(
+ std::fs::read(config.get_messages_file_path(1, 1, 0, 1)).unwrap(),
+ tail_body
+ );
+ drop(partition);
+ let recovered = journal::PartitionPrepareJournal::open(&wal, namespace.inner(), 0)
+ .await
+ .unwrap();
+ assert_eq!(recovered.head(), 2);
+ assert_eq!(
+ recovered.prepares().await.unwrap()[1].as_slice()[size_of::<PrepareHeader>()..],
+ tail_body
+ );
+ }
+
+ #[compio::test]
+ async fn corrupt_prepare_wal_is_quarantined_without_losing_the_recovery_fence() {
+ let root = tempfile::tempdir().unwrap();
+ let config = solo_config(&root);
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let runtime = TopicRuntimeOptions {
+ durability: iggy_common::Durability::Persisted,
+ preallocate_segments: Some(false),
+ ..Default::default()
+ };
+ drop(
+ build_partition_fresh(
+ &config,
+ namespace,
+ Arc::new(PartitionStats::default()),
+ 0,
+ runtime,
+ CLUSTER,
+ REPLICA,
+ REPLICAS,
+ 0,
+ Rc::new(IggyMessageBus::new(0)),
+ )
+ .await
+ .unwrap(),
+ );
+ let directory = config.get_partition_path(1, 1, 0);
+ let (store, _) = open_partition_superblock(&directory, test_identity())
+ .await
+ .unwrap();
+ let state = recorded_state(3, 2);
+ store.write(&state.to_bytes()).await.unwrap();
+ drop(store);
+ let frontier = Path::new(&directory).join("prepares-0/frontier");
+ let mut corrupt = std::fs::read(&frontier).unwrap();
+ corrupt[0] ^= u8::MAX;
+ std::fs::write(&frontier, &corrupt).unwrap();
+ let partitions = solo_partitions();
+ let metadata = Partition::new(0, namespace.inner(), IggyTimestamp::now(), 0, 0);
+
+ for _ in 0..2 {
+ let partition = load_partition_or_fence(
+ &config,
+ namespace,
+ Arc::new(PartitionStats::default()),
+ &metadata,
+ runtime,
+ CLUSTER,
+ REPLICA,
+ REPLICAS,
+ Rc::new(IggyMessageBus::new(0)),
+ &partitions,
+ )
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(partition.requires_state_transfer());
+ assert!(partition.consensus().view() >= state.view);
+ let (_, recovered) = open_partition_superblock(&directory, test_identity())
+ .await
+ .unwrap();
+ assert_eq!(recovered, Some(state));
+ }
+ assert_eq!(
+ std::fs::read(format!("{directory}.fenced.0/prepares-0/frontier")).unwrap(),
+ corrupt,
+ );
+ }
+
fn recorded_state(view: u32, log_view: u32) -> VsrState {
VsrState {
cluster: CLUSTER,
@@ -1521,10 +1834,7 @@
fn solo_config(root: &tempfile::TempDir) -> ServerConfig {
ServerConfig {
- system: Arc::new(ServerSystemConfig {
- path: root.path().to_string_lossy().into_owned(),
- ..ServerSystemConfig::default()
- }),
+ path: root.path().to_string_lossy().into_owned(),
..ServerConfig::default()
}
}
@@ -1555,8 +1865,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
@@ -1586,7 +1895,7 @@
async fn given_a_fresh_solo_partition_when_building_should_record_its_first_claim() {
let root = tempfile::tempdir().expect("tempdir");
let config = solo_config(&root);
- let dir = config.system.get_partition_path(1, 1, 0);
+ let dir = config.get_partition_path(1, 1, 0);
let partition = build_solo_partition(&config)
.await
@@ -1610,7 +1919,7 @@
const RESERVED: u64 = 65_537;
let root = tempfile::tempdir().expect("tempdir");
let config = solo_config(&root);
- let dir = config.system.get_partition_path(1, 1, 0);
+ let dir = config.get_partition_path(1, 1, 0);
let (store, recovered) = open_partition_superblock(&dir, solo_identity())
.await
@@ -1672,13 +1981,13 @@
let root = tempfile::tempdir().expect("tempdir");
let config = solo_config(&root);
let namespace = IggyNamespace::new(1, 1, 0);
- let dir = config.system.get_partition_path(1, 1, 0);
+ let dir = config.get_partition_path(1, 1, 0);
std::fs::create_dir_all(&dir).expect("partition dir");
// Two empty segments make the first a NON-tail empty, the refusal a solo
// group rebuilds through (zero recoverable bytes) instead of tombstoning
// where it stands.
for start_offset in [0, 1] {
- std::fs::File::create(config.system.get_messages_file_path(1, 1, 0, start_offset))
+ std::fs::File::create(config.get_messages_file_path(1, 1, 0, start_offset))
.expect("empty segment log");
}
// The rebuild's claim is this group's first superblock write, so it
diff --git a/core/server/src/partition_reconciler.rs b/core/server/src/partition_reconciler.rs
index 10be2b7..2e1ce4c 100644
--- a/core/server/src/partition_reconciler.rs
+++ b/core/server/src/partition_reconciler.rs
@@ -734,7 +734,6 @@
// hold once the whole cluster restarted.
let partition_dir =
ctx.config
- .system
.get_partition_path(ns.stream_id(), ns.topic_id(), ns.partition_id());
let prior_life_on_disk = std::fs::metadata(&partition_dir).is_ok();
@@ -1472,7 +1471,7 @@
current_revision, delete_partitions_from_disk, fetch_partition_build_inputs,
reconcile_consumer_group_offsets, reconcile_once,
};
- use configs::server::{ServerConfig, ServerSystemConfig};
+ use configs::server::ServerConfig;
use consensus::{MetadataHandle, PartitionsHandle};
use iggy_binary_protocol::codec::WireEncode;
use iggy_binary_protocol::primitives::identifier::WireName;
@@ -1787,17 +1786,10 @@
}
fn test_config(tmp: &TempDir) -> ServerConfig {
- let mut cfg = ServerConfig::default();
- // `ServerSystemConfig` is not `Clone`, so `Arc::make_mut` is out; build a
- // fresh value via struct-update syntax and swap the Arc wholesale.
- // Only `path` differs from the default; every other field uses the
- // runtime's defaults.
- let system = ServerSystemConfig {
+ ServerConfig {
path: tmp.path().to_string_lossy().into_owned(),
- ..ServerSystemConfig::default()
- };
- cfg.system = Arc::new(system);
- cfg
+ ..ServerConfig::default()
+ }
}
/// Assemble a fully functional `ServerShard` for reconciler tests.
@@ -1816,8 +1808,7 @@
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
preallocate_segments: false,
@@ -2486,7 +2477,7 @@
reconcile_pass(&ctx).await;
// Verify disk hierarchy exists before the delete commits.
- let partition_root_before = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root_before = ctx.config.get_partition_path(0, 0, 0);
assert!(
std::path::Path::new(&partition_root_before).exists(),
"partition directory must exist post-materialisation"
@@ -2511,11 +2502,9 @@
None,
"shards_table row must be pruned for {ns:?}"
);
- let path = ctx.config.system.get_partition_path(
- ns.stream_id(),
- ns.topic_id(),
- ns.partition_id(),
- );
+ let path =
+ ctx.config
+ .get_partition_path(ns.stream_id(), ns.topic_id(), ns.partition_id());
assert!(
!std::path::Path::new(&path).exists(),
"on-disk hierarchy for {ns:?} must be removed"
@@ -3184,7 +3173,7 @@
let ns = IggyNamespace::new(0, 0, 0);
let partitions = shard.plane.partitions();
assert!(partitions.contains(&ns));
- let partition_root = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root = ctx.config.get_partition_path(0, 0, 0);
assert!(std::path::Path::new(&partition_root).exists());
// Reconstruct the post-failed-teardown state: tombstone set +
@@ -3263,7 +3252,7 @@
let ns = IggyNamespace::new(0, 0, 0);
let partitions = shard.plane.partitions();
assert!(partitions.contains(&ns));
- let partition_root = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root = ctx.config.get_partition_path(0, 0, 0);
// Post-successful-teardown, pre-drain state: tombstone set +
// shards_table row gone, NO delete failure (the disk delete
@@ -3328,7 +3317,7 @@
None,
"tombstoned namespace must stay unrouted"
);
- let partition_root = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root = ctx.config.get_partition_path(0, 0, 0);
assert!(
!std::path::Path::new(&partition_root).exists(),
"no fresh build may touch the refused files' directory"
@@ -3358,7 +3347,7 @@
let partitions = shard.plane.partitions();
// Boot-fence shape with the refused files still at their real paths.
partitions.tombstone(ns);
- let partition_root = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root = ctx.config.get_partition_path(0, 0, 0);
std::fs::create_dir_all(&partition_root).expect("plant partition dir");
let refused_log = format!("{partition_root}/00000000000000000000.log");
std::fs::write(&refused_log, b"refused bytes").expect("plant refused log");
@@ -3411,7 +3400,7 @@
let ns = IggyNamespace::new(0, 0, 0);
let partitions = shard.plane.partitions();
partitions.tombstone(ns);
- let partition_root = ctx.config.system.get_partition_path(0, 0, 0);
+ let partition_root = ctx.config.get_partition_path(0, 0, 0);
std::fs::create_dir_all(&partition_root).expect("plant partition dir");
let refused_log = format!("{partition_root}/00000000000000000000.log");
std::fs::write(&refused_log, b"refused bytes").expect("plant refused log");
diff --git a/core/server/src/responses.rs b/core/server/src/responses.rs
index 1c6c581..c5fca28 100644
--- a/core/server/src/responses.rs
+++ b/core/server/src/responses.rs
@@ -1063,11 +1063,16 @@
),
},
OptionDescriptor {
- key: WireName::new(topic_option_keys::ENFORCE_FSYNC)
- .map_err(|_| IggyError::InvalidFormat)?,
- kind: HeaderKind::Bool.as_code(),
- default_value: Bytes::copy_from_slice(&[u8::from(iggy_common::DEFAULT_ENFORCE_FSYNC)]),
- description: "Whether writes to this topic's partitions fsync".to_string(),
+ key: WireName::new(topic_option_keys::DURABILITY).map_err(|_| IggyError::InvalidFormat)?,
+ kind: HeaderKind::String.as_code(),
+ default_value: Bytes::from_static(b"replicated"),
+ description: "Message completion: replicated or persisted. Independently defaults to replicated. A singleton quorum has one copy. In replicated groups, persisted messages use WAL references to segment bodies, retaining their inodes by hard link until WAL reclamation. The full body size still counts against partition.wal_bytes_max.".to_string(),
+ },
+ OptionDescriptor {
+ key: WireName::new(topic_option_keys::CONSUMER_OFFSET_DURABILITY).map_err(|_| IggyError::InvalidFormat)?,
+ kind: HeaderKind::String.as_code(),
+ default_value: Bytes::from_static(b"replicated"),
+ description: "Explicit offset completion: replicated or persisted. Independently defaults to replicated. Poll auto-commit remains asynchronous. In replicated groups, persisted offsets also enable WAL references to segment bodies, retaining their inodes by hard link until reclamation, even with replicated message durability. Full body sizes count against partition.wal_bytes_max.".to_string(),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::MESSAGES_REQUIRED_TO_SAVE)
diff --git a/core/server/src/segment_recovery.rs b/core/server/src/segment_recovery.rs
index 0e192c0..ca82b42 100644
--- a/core/server/src/segment_recovery.rs
+++ b/core/server/src/segment_recovery.rs
@@ -76,7 +76,7 @@
/// -- for its whole length on every clean boot.
const INDEX_SCAN_YIELD_STRIDE: u64 = 1024;
-/// Index entries the log may legitimately fail to back under `enforce_fsync`.
+/// Index entries the log may legitimately fail to back under `durable_segments`.
/// Persistence writes exactly one entry per flush chunk and chunks never
/// overlap. The two halves fdatasync concurrently WITHIN one flush, but
/// flushes are serialized, and the log's fdatasync covers the whole file: an
@@ -171,7 +171,7 @@
/// [`ServerError::PartitionRecoveryRefused`] so the caller can fence this one
/// partition instead of taking the node down.
///
-/// `enforce_fsync` is the topic's own effective value, not a hint: it is what
+/// `durable_segments` is the topic's own effective value, not a hint: it is what
/// makes a durable index entry evidence about the log (see
/// [`PartitionRecoveryRefusal::FsyncedLogLoss`]), so passing it wrong either
/// refuses healthy chains or hides previously durable data loss.
@@ -179,20 +179,37 @@
/// Takes no offset ceiling. A legitimate gap is proved by the anchor the boot
/// re-anchor writes beside the segment it plants, not inferred from how far the
/// superblock's reservation happens to reach.
-#[allow(clippy::too_many_lines)]
pub async fn load_persisted_segments(
config: &ServerConfig,
namespace: IggyNamespace,
segment_size: IggyByteSize,
- enforce_fsync: bool,
+ durable_segments: bool,
stats: &PartitionStats,
) -> Result<Vec<RecoveredSegment>, ServerError> {
+ load_persisted_segments_with_checkpoint(
+ config,
+ namespace,
+ segment_size,
+ durable_segments,
+ stats,
+ None,
+ )
+ .await
+}
+
+#[allow(clippy::too_many_lines)]
+pub async fn load_persisted_segments_with_checkpoint(
+ config: &ServerConfig,
+ namespace: IggyNamespace,
+ segment_size: IggyByteSize,
+ durable_segments: bool,
+ stats: &PartitionStats,
+ checkpoint: Option<journal::partition_journal::SegmentPosition>,
+) -> Result<Vec<RecoveredSegment>, ServerError> {
let stream_id = namespace.stream_id();
let topic_id = namespace.topic_id();
let partition_id = namespace.partition_id();
- let partition_path = config
- .system
- .get_partition_path(stream_id, topic_id, partition_id);
+ let partition_path = config.get_partition_path(stream_id, topic_id, partition_id);
let identity = PartitionIdentity {
partition_path: &partition_path,
stream_id,
@@ -206,6 +223,11 @@
// sweep's silent return would swallow an EACCES that must not be ignored.
let mut start_offsets = sweep_scratch_files_and_collect_offsets(&partition_path)?;
start_offsets.sort_unstable();
+ if let Some(checkpoint) = checkpoint {
+ start_offsets.retain(|offset| {
+ *offset < checkpoint.next_offset || *offset == checkpoint.start_offset
+ });
+ }
let max_size = segment_size;
let mut scratch = ScanScratch::default();
@@ -219,26 +241,58 @@
let mut planned = Vec::with_capacity(start_offsets.len());
for start_offset in start_offsets {
let messages_path =
- config
- .system
- .get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
- let index_path =
- config
- .system
- .get_index_path(stream_id, topic_id, partition_id, start_offset);
+ config.get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
+ let index_path = config.get_index_path(stream_id, topic_id, partition_id, start_offset);
let raw_messages_size = file_len(&messages_path)?;
-
- let bounds = recover_segment_bounds(
- identity,
- &index_path,
- &messages_path,
- start_offset,
- raw_messages_size,
- enforce_fsync,
- &mut scratch,
- )
- .await?;
+ let checkpoint_segment =
+ checkpoint.is_some_and(|checkpoint| checkpoint.start_offset == start_offset);
+ let messages_size = checkpoint
+ .filter(|checkpoint| checkpoint.start_offset == start_offset)
+ .map_or(raw_messages_size, |checkpoint| checkpoint.length);
+ if raw_messages_size < messages_size {
+ return Err(
+ identity.refusal(PartitionRecoveryRefusal::StorageSizeMismatch {
+ start_offset,
+ on_disk_bytes: raw_messages_size,
+ expected_bytes: messages_size,
+ }),
+ );
+ }
+ let bounds = if checkpoint_segment {
+ let messages = open_messages_file(identity, &messages_path)?;
+ let mut scanner = FileScanner::new(&messages, messages_size, &mut scratch);
+ recover_by_walking_log(
+ identity,
+ &mut scanner,
+ &messages_path,
+ start_offset,
+ messages_size,
+ )
+ .await?
+ } else {
+ recover_segment_bounds(
+ identity,
+ &index_path,
+ &messages_path,
+ start_offset,
+ raw_messages_size,
+ durable_segments,
+ &mut scratch,
+ )
+ .await?
+ };
+ if checkpoint_segment
+ && bounds.as_ref().map_or(0, |bounds| bounds.messages_size) != messages_size
+ {
+ return Err(
+ identity.refusal(PartitionRecoveryRefusal::CheckpointSizeMismatch {
+ start_offset,
+ validated_bytes: bounds.as_ref().map_or(0, |bounds| bounds.messages_size),
+ expected_bytes: messages_size,
+ }),
+ );
+ }
// `bounds == None` means the log holds no whole batch ANYWHERE: the
// index-less walk tried from byte 0 and the damage probe found no
@@ -251,7 +305,7 @@
// tail-only -- the log and the index persist concurrently under
// every config, so a torn index is reachable mid-chain, which is why
// the walk exists rather than refusing the partition.
- let recovered_empty = bounds.is_none();
+ let recovered_empty = bounds.is_none() && !checkpoint_segment;
let bounds = bounds.unwrap_or_else(|| {
if raw_messages_size > 0 {
warn!(
@@ -337,21 +391,35 @@
// index over the shortened log; the next boot re-discards it and
// rebuilds from the log again, and truncation is monotone, so the
// pair converges.
- truncate_to(&plan.messages_path, messages_size)?;
+ if checkpoint.is_none_or(|checkpoint| checkpoint.start_offset != plan.segment.start_offset)
+ {
+ truncate_to(&plan.messages_path, messages_size)?;
+ }
if let Some(staging_path) = &plan.rebuilt_index_staging {
install_rebuilt_index(staging_path, &plan.index_path, identity.partition_path)?;
} else {
truncate_to(&plan.index_path, plan.index_size)?;
}
- let storage = SegmentStorage::new(
- &plan.messages_path,
- &plan.index_path,
- messages_size,
- plan.index_size,
- true,
- )
- .await
+ let storage = if checkpoint.is_some() {
+ SegmentStorage::with_read_only_messages(
+ &plan.messages_path,
+ &plan.index_path,
+ plan.index_size,
+ true,
+ None,
+ )
+ .await
+ } else {
+ SegmentStorage::new(
+ &plan.messages_path,
+ &plan.index_path,
+ messages_size,
+ plan.index_size,
+ true,
+ )
+ .await
+ }
.map_err(|source| {
error!(
stream_id,
@@ -1113,14 +1181,14 @@
/// Derives a segment's readable bounds. `None` when the log holds no whole
/// batch at all (the caller recovers the segment as empty).
///
-/// Without `enforce_fsync`, a consistent index locates batches but cannot prove
+/// Without `durable_segments`, a consistent index locates batches but cannot prove
/// any log page reached disk: page-cache writeback may preserve a later chunk
/// while losing an earlier one. Recovery therefore checksum-walks the log from
/// byte 0. A clean walk preserves the existing index, while a break falls
/// through to the rebuilding walk so every retained entry describes verified
/// bytes.
///
-/// With `enforce_fsync`, completed serialized flushes prove the prefix before
+/// With `durable_segments`, completed serialized flushes prove the prefix before
/// the final index entry, so the last entry's `position` anchors the walk that
/// proves where the segment really ends. An index whose last entry the log
/// cannot back, or whose entries contradict each other, is dropped whole: the
@@ -1137,7 +1205,7 @@
messages_path: &str,
start_offset: u64,
messages_size: u64,
- enforce_fsync: bool,
+ durable_segments: bool,
scratch: &mut ScanScratch,
) -> Result<Option<WalkedBounds>, ServerError> {
let (entry_count, first, last) = load_index_anchors(identity, index_path).await?;
@@ -1146,7 +1214,7 @@
(Some(first), Some(last)) => {
// A mis-strided or foreign index decodes to garbage entries that
// binary searches would trust, so an index that contradicts itself
- // is dropped whole and rebuilt from the log. No `enforce_fsync`
+ // is dropped whole and rebuilt from the log. No `durable_segments`
// gate here, unlike the step-back below: the writer cannot emit a
// non-ascending run, so this file is foreign or mis-strided and is
// no witness to what the log once held.
@@ -1190,7 +1258,7 @@
)
.await?;
if walk.start_timestamp.is_none() {
- // Under `enforce_fsync` the DEPTH of the step-back that would
+ // Under `durable_segments` the DEPTH of the step-back that would
// find a usable non-empty anchor is evidence about the LOG.
// Flushes are serialized and each one fdatasyncs the whole log
// file before the next one writes, so an entry existing above
@@ -1201,7 +1269,7 @@
// this path too: it carries no message range recovery may
// advertise and the production writer never emits one.
// Refuse and keep every byte for the operator.
- if enforce_fsync {
+ if durable_segments {
let search = find_provable_index_anchor(
identity,
index_path,
@@ -1231,7 +1299,7 @@
// stopped: a hole below the anchor would be invisible, while
// `end_offset` still advertised the offsets over it. That hole
// is exactly what the crash reaching this branch can leave --
- // without `enforce_fsync` writeback order is arbitrary, so an
+ // without `durable_segments` writeback order is arbitrary, so an
// unprovable tail entry is equally a torn INDEX tail and
// evidence the LOG lost an interior page. Walk from byte 0
// instead: it reads the damage, finds the anchor's own batch
@@ -1259,7 +1327,7 @@
messages_size,
)
.await?;
- if enforce_fsync {
+ if durable_segments {
ensure_fsynced_rebuild_reaches(
identity,
rebuilt.as_ref(),
@@ -1280,7 +1348,7 @@
messages_size,
)
.await?;
- if enforce_fsync {
+ if durable_segments {
ensure_fsynced_rebuild_reaches(
identity,
rebuilt.as_ref(),
@@ -1292,7 +1360,7 @@
return Ok(rebuilt);
}
- if !enforce_fsync {
+ if !durable_segments {
// The last entry proved that the index still describes this
// log. It does not prove earlier log pages reached disk. The
// first entry is exactly `(start_offset, position 0)` by the
@@ -1757,7 +1825,7 @@
/// reported as the number of entries at or below it plus that entry's
/// position, alongside how deep the search went.
///
-/// Reached only under `enforce_fsync`, and only to measure how far the index
+/// Reached only under `durable_segments`, and only to measure how far the index
/// has outrun the log: the index is dropped whole either way, so nothing is
/// anchored on the entry this returns. What the DEPTH decides is whether the
/// gap is the one chunk a crash can strand or previously durable data the log lost
@@ -2139,7 +2207,7 @@
/// into a log it has reason to distrust.
///
/// The residue is deliberately NOT width-gated: a torn flush chunk is
-/// bounded by the CHUNK, not by one record, and with `enforce_fsync = false`
+/// bounded by the CHUNK, not by one record, and with `durable_segments = false`
/// delayed allocation routinely extends a file far past its written-back
/// pages, leaving hundreds of MiB of zeros behind one crash. That is the
/// canonical torn tail this module exists to truncate, so every residue is
@@ -2481,14 +2549,13 @@
mod tests {
use super::*;
use bytes::Bytes;
- use configs::server::ServerSystemConfig;
+ use configs::server::ServerConfig;
use partitions::segment_anchor::SegmentAnchor;
use server_common::send_messages::{
IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned, calculate_batch_checksum,
};
use server_common::sharding::IggyNamespace;
use std::os::unix::fs::symlink;
- use std::sync::Arc;
use tempfile::{TempDir, tempdir};
const STREAM_ID: usize = 1;
@@ -2501,21 +2568,14 @@
const GARBAGE: [u8; 384] = [0xAB; 384];
fn test_config(tmp: &TempDir) -> ServerConfig {
- let mut config = ServerConfig::default();
- // `ServerSystemConfig` is not `Clone`; build a fresh value and swap
- // the whole `Arc`.
- let system = ServerSystemConfig {
+ ServerConfig {
path: tmp.path().to_string_lossy().into_owned(),
- ..ServerSystemConfig::default()
- };
- config.system = Arc::new(system);
- config
+ ..ServerConfig::default()
+ }
}
fn prepare_partition_dir(config: &ServerConfig) -> String {
- let partition_path = config
- .system
- .get_partition_path(STREAM_ID, TOPIC_ID, PARTITION_ID);
+ let partition_path = config.get_partition_path(STREAM_ID, TOPIC_ID, PARTITION_ID);
fs::create_dir_all(&partition_path).expect("create partition dir");
partition_path
}
@@ -2629,13 +2689,8 @@
index: &[u8],
) -> (String, String) {
let messages_path =
- config
- .system
- .get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, start_offset);
- let index_path =
- config
- .system
- .get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, start_offset);
+ config.get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, start_offset);
+ let index_path = config.get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, start_offset);
fs::write(&messages_path, log).expect("write log fixture");
fs::write(&index_path, index).expect("write index fixture");
(messages_path, index_path)
@@ -2644,9 +2699,7 @@
/// Path of the anchor beside the segment planted at `start_offset`.
fn anchor_fixture_path(config: &ServerConfig, start_offset: u64) -> String {
partitions::segment_anchor::anchor_path(
- &config
- .system
- .get_partition_path(STREAM_ID, TOPIC_ID, PARTITION_ID),
+ &config.get_partition_path(STREAM_ID, TOPIC_ID, PARTITION_ID),
start_offset,
)
}
@@ -2703,20 +2756,20 @@
async fn recover_under_fsync(
config: &ServerConfig,
- enforce_fsync: bool,
+ durable_segments: bool,
) -> Result<Vec<RecoveredSegment>, ServerError> {
- recover_with(config, enforce_fsync).await
+ recover_with(config, durable_segments).await
}
async fn recover_with(
config: &ServerConfig,
- enforce_fsync: bool,
+ durable_segments: bool,
) -> Result<Vec<RecoveredSegment>, ServerError> {
load_persisted_segments(
config,
IggyNamespace::new(STREAM_ID, TOPIC_ID, PARTITION_ID),
IggyByteSize::from(SEGMENT_MAX_SIZE),
- enforce_fsync,
+ durable_segments,
&PartitionStats::default(),
)
.await
@@ -2830,13 +2883,8 @@
prepare_partition_dir(&config);
let mut log = encoded_batch(0, 1);
log.extend_from_slice(&GARBAGE);
- let messages_path =
- config
- .system
- .get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
- let index_path = config
- .system
- .get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let messages_path = config.get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let index_path = config.get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
fs::write(&messages_path, &log).expect("write log fixture");
// Self-referential symlink: every open or stat that follows it fails
// with ELOOP, root or not (unlike permission bits, which root
@@ -2869,13 +2917,8 @@
let tmp = tempdir().expect("tempdir");
let config = test_config(&tmp);
prepare_partition_dir(&config);
- let messages_path =
- config
- .system
- .get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
- let index_path = config
- .system
- .get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let messages_path = config.get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let index_path = config.get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
fs::write(&index_path, &GARBAGE[..10]).expect("write torn index fixture");
// See the index variant above; the log stem is still collected by the
// directory sweep, so recovery reaches the stat and must fail stop
@@ -3331,6 +3374,113 @@
);
}
+ #[compio::test]
+ async fn given_a_later_checkpoint_when_recovering_should_preserve_sealed_index_evidence() {
+ for damaged in [false, true] {
+ let directory = tempdir().unwrap();
+ let config = test_config(&directory);
+ prepare_partition_dir(&config);
+ let first = encoded_batch(0, 1);
+ let mut sealed = first.clone();
+ sealed.extend(encoded_batch(1, 1));
+ let mut index = index_entry(0, 0);
+ index.extend(index_entry(1, first.len() as u64));
+ if damaged {
+ index.extend(index_entry(2, sealed.len() as u64 + 8));
+ index.extend(index_entry(3, sealed.len() as u64 + 128));
+ }
+ let (messages_path, index_path) = write_segment(&config, 0, &sealed, &index);
+ let tail = encoded_batch(2, 1);
+ write_segment(&config, 2, &tail, &index_entry(2, 0));
+ let checkpoint = journal::partition_journal::SegmentPosition {
+ start_offset: 2,
+ length: tail.len() as u64,
+ next_offset: 3,
+ };
+ let result = load_persisted_segments_with_checkpoint(
+ &config,
+ IggyNamespace::new(STREAM_ID, TOPIC_ID, PARTITION_ID),
+ IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
+ true,
+ &PartitionStats::default(),
+ Some(checkpoint),
+ )
+ .await;
+ if damaged {
+ assert!(
+ matches!(
+ result,
+ Err(ServerError::PartitionRecoveryRefused {
+ reason: PartitionRecoveryRefusal::FsyncedLogLoss { .. },
+ ..
+ })
+ ),
+ "sealed index evidence must not be erased by an unrelated checkpoint"
+ );
+ } else {
+ let recovered = result.unwrap();
+ assert_eq!(recovered.len(), 2);
+ assert_eq!(recovered[0].segment.end_offset, 1);
+ assert!(recovered[0].storage.messages_writer.is_none());
+ }
+ assert_eq!(bytes_of(&messages_path), sealed);
+ assert_eq!(bytes_of(&index_path), index);
+ }
+ }
+
+ #[compio::test]
+ async fn given_checkpointed_prefix_when_recovering_should_hide_tails_and_refuse_overlaps() {
+ let tmp = tempdir().unwrap();
+ let config = test_config(&tmp);
+ prepare_partition_dir(&config);
+ let committed = encoded_batch(0, 3);
+ let mut physical = committed.clone();
+ physical.extend(encoded_batch(3, 3));
+ let (messages_path, _) = write_segment(&config, 0, &physical, &index_entry(0, 0));
+ let (uncommitted_path, _) = write_segment(&config, 6, &encoded_batch(6, 3), &[]);
+ let checkpoint = journal::partition_journal::SegmentPosition {
+ start_offset: 0,
+ length: committed.len() as u64,
+ next_offset: 3,
+ };
+ let namespace = IggyNamespace::new(STREAM_ID, TOPIC_ID, PARTITION_ID);
+ let recovered = load_persisted_segments_with_checkpoint(
+ &config,
+ namespace,
+ IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
+ true,
+ &PartitionStats::default(),
+ Some(checkpoint),
+ )
+ .await
+ .unwrap();
+ assert_eq!(recovered.len(), 1);
+ assert_eq!(recovered[0].segment.end_offset, 2);
+ assert_eq!(recovered[0].segment.size.as_bytes_u64(), checkpoint.length);
+ assert_eq!(bytes_of(&messages_path), physical);
+ assert!(Path::new(&uncommitted_path).exists());
+ drop(recovered);
+ let (overlap, _) = write_segment(&config, 1, &[], &[]);
+ let result = load_persisted_segments_with_checkpoint(
+ &config,
+ namespace,
+ IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
+ true,
+ &PartitionStats::default(),
+ Some(checkpoint),
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(ServerError::PartitionRecoveryRefused {
+ reason: PartitionRecoveryRefusal::Hole { .. },
+ ..
+ })
+ ));
+ assert!(Path::new(&overlap).exists());
+ assert_eq!(bytes_of(&messages_path), physical);
+ }
+
/// No re-anchor plants a segment whose range a predecessor already covers, so
/// an anchor must not launder an overlap either. Reachable because each end
/// offset is walked from its own file with nothing clamping it against the
@@ -3649,7 +3799,7 @@
let tmp = tempdir().expect("tempdir");
let config = test_config(&tmp);
prepare_partition_dir(&config);
- // The canonical torn flush chunk: a crash under `enforce_fsync =
+ // The canonical torn flush chunk: a crash under `durable_segments =
// false` leaves the file extended far past its written-back pages,
// reading as zeros -- residue bounded by the CHUNK (up to a whole
// segment), not by one record. No survivor decodes anywhere in it,
@@ -4372,7 +4522,7 @@
let config = test_config(&tmp);
prepare_partition_dir(&config);
// The gap between the index and the log is exactly one entry here, so
- // the `enforce_fsync` guard reads this as the benign in-flight chunk
+ // the `durable_segments` guard reads this as the benign in-flight chunk
// and lets it through. That verdict is about the INDEX; the log is
// still walked from byte 0, which is what catches the damage.
let (log, index, damage_position, _) = segment_damaged_below_its_last_provable_entry();
@@ -4581,7 +4731,7 @@
// Two unprovable entries for two different reasons: one lands
// mid-batch inside the log, the next past its end. Neither the depth
// of the gap nor the reason for it changes the verdict without
- // `enforce_fsync`: the whole index goes and the log speaks for itself.
+ // `durable_segments`: the whole index goes and the log speaks for itself.
let batch0 = encoded_batch(0, 1);
let batch1 = encoded_batch(1, 1);
let mut log = batch0.clone();
@@ -4612,7 +4762,7 @@
let config = test_config(&tmp);
prepare_partition_dir(&config);
// The same fixture the lenient rebuild above accepts. Under
- // `enforce_fsync` the second-from-last entry was durable before its
+ // `durable_segments` the second-from-last entry was durable before its
// chunk was acked, so a log that cannot back it lost acked bytes.
let batch0 = encoded_batch(0, 1);
let batch1 = encoded_batch(1, 1);
@@ -4627,7 +4777,7 @@
let error = recover_under_fsync(&config, true)
.await
.err()
- .expect("a multi-entry overshoot under enforce_fsync must refuse");
+ .expect("a multi-entry overshoot under durable_segments must refuse");
assert!(
matches!(
@@ -4653,7 +4803,7 @@
let tmp = tempdir().expect("tempdir");
let config = test_config(&tmp);
prepare_partition_dir(&config);
- // The crash window `enforce_fsync` cannot close: the entry for the
+ // The crash window `durable_segments` cannot close: the entry for the
// chunk still in flight reached disk, its log bytes did not, and no
// ack was ever sent for them. Exactly one entry deep, so it recovers.
let batch0 = encoded_batch(0, 1);
@@ -4811,7 +4961,7 @@
let error = recover_under_fsync(&config, true)
.await
.err()
- .expect("an index backed nowhere under enforce_fsync must refuse, not rebuild");
+ .expect("an index backed nowhere under durable_segments must refuse, not rebuild");
assert!(
matches!(
@@ -4884,7 +5034,7 @@
// The first flush into a fresh segment, crashed between the two
// fsyncs: one entry and no log bytes. Whether any batches in that
// flush were acknowledged depends on the flush thresholds, but only
- // one entry can belong to the interrupted flush. `enforce_fsync` must
+ // one entry can belong to the interrupted flush. `durable_segments` must
// not turn that shape into a tombstone.
let index = index_entry(0, 0);
let (messages_path, index_path) = write_segment(&config, 0, &[], &index);
@@ -4965,7 +5115,7 @@
// costs a whole-batch verify that never passes. The claims overlap
// many times over, so an unbudgeted search would hash close to
// entries x claim bytes; it must give up and refuse instead, leaving
- // the files byte-identical. Only `enforce_fsync` walks the index
+ // the files byte-identical. Only `durable_segments` walks the index
// backward at all -- without it the gap is not measured, so there is
// nothing here to bound.
const CLAIMED_BATCH_BYTES: usize = 8 * 1024;
@@ -5211,13 +5361,8 @@
let config = test_config(&tmp);
prepare_partition_dir(&config);
let log = encoded_batch(0, 4);
- let messages_path =
- config
- .system
- .get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
- let index_path = config
- .system
- .get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let messages_path = config.get_messages_file_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
+ let index_path = config.get_index_path(STREAM_ID, TOPIC_ID, PARTITION_ID, 0);
fs::write(&messages_path, &log).expect("write log fixture");
let recovered = recover(&config)
diff --git a/core/server/src/server_error.rs b/core/server/src/server_error.rs
index 517d3be..b32a8ab 100644
--- a/core/server/src/server_error.rs
+++ b/core/server/src/server_error.rs
@@ -62,7 +62,7 @@
},
#[error(
"shard allocator produced zero shards; server must run at least one \
- shard (check [system.sharding] cpu_allocation)"
+ shard (check [sharding] cpu_allocation)"
)]
ShardsCountZero,
#[error(
@@ -100,35 +100,35 @@
shard_id: u16,
timeout: std::time::Duration,
},
- #[error("system.sharding.inbox_capacity must be in 1..={max}; got {value}")]
+ #[error("sharding.inbox_capacity must be in 1..={max}; got {value}")]
InvalidInboxCapacity { value: usize, max: usize },
- #[error("system.sharding.reply_inbox_capacity must be in 1..={max}; got {value}")]
+ #[error("sharding.reply_inbox_capacity must be in 1..={max}; got {value}")]
InvalidReplyInboxCapacity { value: usize, max: usize },
- #[error("system.sharding.shutdown_drain_timeout must be in (0, {max:?}]; got {value:?}")]
+ #[error("sharding.shutdown_drain_timeout must be in (0, {max:?}]; got {value:?}")]
InvalidShutdownDrainTimeout {
value: std::time::Duration,
max: std::time::Duration,
},
- #[error("system.sharding.shutdown_poll_interval must be in (0, {max:?}]; got {value:?}")]
+ #[error("sharding.shutdown_poll_interval must be in (0, {max:?}]; got {value:?}")]
InvalidShutdownPollInterval {
value: std::time::Duration,
max: std::time::Duration,
},
#[error(
- "system.sharding.shutdown_poll_interval ({poll:?}) must be <= \
+ "sharding.shutdown_poll_interval ({poll:?}) must be <= \
shutdown_drain_timeout ({drain:?})"
)]
ShutdownPollExceedsDrain {
poll: std::time::Duration,
drain: std::time::Duration,
},
- #[error("system.sharding.shutdown_join_timeout must be <= {max:?}; got {value:?}")]
+ #[error("sharding.shutdown_join_timeout must be <= {max:?}; got {value:?}")]
InvalidShutdownJoinTimeout {
value: std::time::Duration,
max: std::time::Duration,
},
#[error(
- "system.sharding.shutdown_join_timeout ({join:?}) must be >= \
+ "sharding.shutdown_join_timeout ({join:?}) must be >= \
shutdown_drain_timeout ({drain:?})"
)]
ShutdownJoinBelowDrain {
@@ -136,7 +136,7 @@
drain: std::time::Duration,
},
#[error(
- "system.sharding.reconcile_periodic_interval must be in (0, {max:?}]; got {value:?}. \
+ "sharding.reconcile_periodic_interval must be in (0, {max:?}]; got {value:?}. \
Note that \"0\", \"none\", \"unlimited\", and \"disabled\" all parse to zero"
)]
InvalidReconcilePeriodicInterval {
@@ -161,6 +161,12 @@
#[source]
source: std::io::Error,
},
+ #[error("failed to recover partition prepare WAL at {dir}: {source}")]
+ PartitionPrepareWalIo {
+ dir: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
// Quarantines the one partition rather than treating the group as fresh or
// reading through to a superseded view: mirrors the metadata plane's
// `RecoveryError::SuperblockUnreadable` policy, minus the boot refusal,
@@ -212,7 +218,7 @@
// catch this error, and only they log it -- a claim here would render
// beside theirs and contradict one branch or the other.
#[error(
- "partition {stream_id}/{topic_id}/{partition_id} at {dir} refused segment \
+ "partition {stream_id}/{topic_id}/{partition_id} at {dir} refused storage \
recovery: {reason}"
)]
PartitionRecoveryRefused {
@@ -348,10 +354,10 @@
/// into byte-clean files by an upstream crash window as well as by damage.
///
/// An index that contradicts itself is deliberately NOT here, and neither is
-/// one the log cannot back UNLESS the topic runs under `enforce_fsync` and the
+/// one the log cannot back UNLESS the topic runs under `persisted durability` and the
/// gap is deeper than the single in-flight entry: entries are derived from the
/// log, so recovery drops such an index whole and rebuilds it from a byte-0
-/// walk of the log rather than believing any part of it. What `enforce_fsync`
+/// walk of the log rather than believing any part of it. What `persisted durability`
/// adds is evidence from serialized completed flushes: an entry above chunk N
/// means the log fdatasync covering chunk N completed before the later flush
/// began. This is independent of reply timing and turns a deeper gap into
@@ -428,7 +434,7 @@
batch_partition_id: u64,
position: u64,
},
- /// The sparse index of a topic running under `enforce_fsync` outruns its
+ /// The sparse index of a topic running under `persisted durability` outruns its
/// log by more than the one entry a crash can legitimately strand there.
/// Persistence writes exactly one entry per flush chunk, chunks never
/// overlap, and flushes are serialized, so every entry below the last one
@@ -452,7 +458,7 @@
/// backs nothing.
searched_entries: u64,
},
- /// Under `enforce_fsync`, the byte-0 rebuild after a dropped index proved
+ /// Under `persisted durability`, the byte-0 rebuild after a dropped index proved
/// the log only through `walked_position`, short of `durable_position`,
/// the byte the index's own last entry proves the log had already
/// fdatasynced through (the flush that wrote the entry began only after
@@ -466,8 +472,16 @@
walked_position: u64,
durable_position: u64,
},
- /// A writer reopening over recovered bounds found the on-disk length
- /// diverging from the size recovery just validated and truncated to.
+ PrepareWal {
+ directory: PathBuf,
+ source: std::io::Error,
+ },
+ CheckpointSizeMismatch {
+ start_offset: u64,
+ validated_bytes: u64,
+ expected_bytes: u64,
+ },
+ /// The physical file length differs from the required recovered boundary.
StorageSizeMismatch {
start_offset: u64,
on_disk_bytes: u64,
@@ -558,7 +572,7 @@
searched_entries,
} => write!(
f,
- "segment {start_offset} runs under enforce_fsync with {entry_count} sparse \
+ "segment {start_offset} runs under persisted durability with {entry_count} sparse \
index entries, but its log backs only {provable_entries} of the \
{searched_entries} searched from the top (up to byte {provable_position}); \
every entry below the last describes a log chunk whose fdatasync had \
@@ -572,13 +586,27 @@
durable_position,
} => write!(
f,
- "segment {start_offset} runs under enforce_fsync with {entry_count} sparse \
+ "segment {start_offset} runs under persisted durability with {entry_count} sparse \
index entries, and the byte-0 rebuild proved its log only through byte \
{walked_position}, short of byte {durable_position} which the last \
entry's own fdatasync ordering proves the log had already made durable; \
the log has lost previously durable bytes mid-chunk, so rebuilding \
would re-mint their offsets"
),
+ Self::PrepareWal { directory, source } => write!(
+ f,
+ "prepare WAL at {} cannot be recovered: {source}",
+ directory.display()
+ ),
+ Self::CheckpointSizeMismatch {
+ start_offset,
+ validated_bytes,
+ expected_bytes,
+ } => write!(
+ f,
+ "segment {start_offset} validated prefix has {validated_bytes} bytes, \
+ but the WAL checkpoint requires {expected_bytes}"
+ ),
Self::StorageSizeMismatch {
start_offset,
on_disk_bytes,
@@ -586,7 +614,7 @@
} => write!(
f,
"segment {start_offset} file length {on_disk_bytes} diverged from \
- its recovered size {expected_bytes} at writer open"
+ its required recovered size {expected_bytes}"
),
}
}
diff --git a/core/server/src/snapshot.rs b/core/server/src/snapshot.rs
index fca3b6e..87122b8 100644
--- a/core/server/src/snapshot.rs
+++ b/core/server/src/snapshot.rs
@@ -29,7 +29,7 @@
use async_zip::base::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
-use configs::server::ServerSystemConfig;
+use configs::server::ServerConfig;
use futures::channel::oneshot;
use iggy_common::{IggyDuration, IggyError, SnapshotCompression, SystemSnapshotType};
use tracing::{error, info, warn};
@@ -55,7 +55,7 @@
/// [`SNAPSHOT_IN_PROGRESS`]); a concurrent request busy-rejects with
/// [`IggyError::SnapshotFileCompletionFailed`].
pub async fn collect(
- system_config: Arc<ServerSystemConfig>,
+ server_config: Arc<ServerConfig>,
compression: SnapshotCompression,
snapshot_types: Vec<SystemSnapshotType>,
) -> Result<Vec<u8>, IggyError> {
@@ -89,7 +89,7 @@
// A dropped receiver means the requester disconnected while
// collecting; there is nobody left to deliver to.
let _ = result_sender.send(collect_blocking(
- &system_config,
+ &server_config,
compression,
&snapshot_types,
));
@@ -128,14 +128,14 @@
}
fn collect_blocking(
- system_config: &ServerSystemConfig,
+ server_config: &ServerConfig,
compression: SnapshotCompression,
snapshot_types: &[SystemSnapshotType],
) -> Result<Vec<u8>, IggyError> {
let started = Instant::now();
let mut entries = Vec::with_capacity(snapshot_types.len());
for snapshot_type in snapshot_types {
- match capture(snapshot_type, system_config) {
+ match capture(snapshot_type, server_config) {
Ok(content) => entries.push((format!("{snapshot_type}.txt"), content)),
// Parity with the legacy collector: a failed section is logged and
// skipped so the rest of the archive still ships.
@@ -156,7 +156,7 @@
fn capture(
snapshot_type: &SystemSnapshotType,
- system_config: &ServerSystemConfig,
+ server_config: &ServerConfig,
) -> io::Result<Vec<u8>> {
match snapshot_type {
SystemSnapshotType::FilesystemOverview => {
@@ -167,8 +167,8 @@
command_stdout(Command::new("top").args(["-H", "-b", "-n", "1"]))
}
SystemSnapshotType::Test => command_stdout(Command::new("echo").arg("test")),
- SystemSnapshotType::ServerLogs => server_logs(system_config),
- SystemSnapshotType::ServerConfig => server_config(system_config),
+ SystemSnapshotType::ServerLogs => server_logs(server_config),
+ SystemSnapshotType::ServerConfig => read_server_config(server_config),
// `collect` expands `All` before handing off to the collector.
SystemSnapshotType::All => Err(io::Error::other("`all` must be expanded by the caller")),
}
@@ -195,17 +195,17 @@
Ok(content)
}
-fn server_logs(system_config: &ServerSystemConfig) -> io::Result<Vec<u8>> {
+fn server_logs(server_config: &ServerConfig) -> io::Result<Vec<u8>> {
// Mirror the logger's path derivation (server_common `Logging::late_init`):
// it canonicalizes the configured subdirectory before joining the system
// path, so a relative `logging.path` that already exists resolves against the
// CWD. Skipping the canonicalize here would read a different (often empty)
// directory than the one the logger actually writes to.
- let logs_subdirectory = PathBuf::from(&system_config.logging.path);
+ let logs_subdirectory = PathBuf::from(&server_config.logging.path);
let logs_subdirectory = logs_subdirectory
.canonicalize()
.unwrap_or(logs_subdirectory);
- let logs_path = PathBuf::from(system_config.get_system_path()).join(logs_subdirectory);
+ let logs_path = PathBuf::from(server_config.get_system_path()).join(logs_subdirectory);
let mut log_files = Vec::new();
for entry in std::fs::read_dir(&logs_path)? {
let entry = entry?;
@@ -224,8 +224,8 @@
Ok(content)
}
-fn server_config(system_config: &ServerSystemConfig) -> io::Result<Vec<u8>> {
- let config_path = PathBuf::from(system_config.get_runtime_path()).join("current_config.toml");
+fn read_server_config(server_config: &ServerConfig) -> io::Result<Vec<u8>> {
+ let config_path = PathBuf::from(server_config.get_runtime_path()).join("current_config.toml");
std::fs::read(config_path)
}
@@ -277,7 +277,7 @@
// second collector thread) rather than piling up threads.
let held = SnapshotInProgressGuard::acquire().expect("flag starts free");
let result = futures::executor::block_on(collect(
- Arc::new(ServerSystemConfig::default()),
+ Arc::new(ServerConfig::default()),
SnapshotCompression::Stored,
vec![SystemSnapshotType::Test],
));
diff --git a/core/server/tests/sdk_e2e.rs b/core/server/tests/sdk_e2e.rs
index 0e52e31..4e20ce9 100644
--- a/core/server/tests/sdk_e2e.rs
+++ b/core/server/tests/sdk_e2e.rs
@@ -53,10 +53,10 @@
impl TestServer {
fn start() -> Self {
- let data_dir = TempDir::new().expect("tempdir for system.path");
+ let data_dir = TempDir::new().expect("tempdir for path");
let mut cmd = Command::cargo_bin("iggy-server")
.expect("iggy-server binary must be built by the test runner");
- cmd.env("IGGY_SYSTEM_PATH", data_dir.path())
+ cmd.env("IGGY_PATH", data_dir.path())
// Ephemeral port; the actual bound port is read back from
// `runtime/current_config.toml` after the listener binds.
.env("IGGY_TCP_ADDRESS", "127.0.0.1:0")
diff --git a/core/server_common/src/fs_utils.rs b/core/server_common/src/fs_utils.rs
index b0bc4ea..c64090c 100644
--- a/core/server_common/src/fs_utils.rs
+++ b/core/server_common/src/fs_utils.rs
@@ -18,6 +18,13 @@
use compio::fs;
use std::io;
use std::path::{Path, PathBuf};
+use tracing::warn;
+
+#[cfg(target_os = "linux")]
+use nix::fcntl::{FallocateFlags, fallocate};
+
+#[cfg(not(target_os = "linux"))]
+static PREALLOCATION_UNAVAILABLE: std::sync::Once = std::sync::Once::new();
#[derive(Debug, Clone)]
pub struct DirEntry {
@@ -44,6 +51,45 @@
}
}
+/// Reserve segment space without changing its contents or logical length.
+/// Unsupported or failed reservations fall back to buffered allocation.
+#[cfg(target_os = "linux")]
+pub fn preallocate_file(file: &fs::File, file_path: &Path, len: u64) {
+ let Ok(len) = i64::try_from(len) else {
+ warn!(
+ target: "iggy.partitions.storage",
+ file = %file_path.display(),
+ preallocate_len = len,
+ "file preallocation size is unsupported, using buffered allocation"
+ );
+ return;
+ };
+
+ // Shard runtimes disable the worker pool, so this opt-in reservation runs
+ // inline. Slow filesystem allocation stalls the shard until it returns.
+ if let Err(error) = fallocate(file, FallocateFlags::FALLOC_FL_KEEP_SIZE, 0, len) {
+ warn!(
+ target: "iggy.partitions.storage",
+ file = %file_path.display(),
+ preallocate_len = len,
+ %error,
+ "file preallocation failed, using buffered allocation"
+ );
+ }
+}
+
+/// Reserve segment space when supported, without changing its logical length.
+#[cfg(not(target_os = "linux"))]
+pub fn preallocate_file(_file: &fs::File, file_path: &Path, _len: u64) {
+ PREALLOCATION_UNAVAILABLE.call_once(|| {
+ warn!(
+ target: "iggy.partitions.storage",
+ file = %file_path.display(),
+ "file preallocation is unavailable on this platform, using buffered allocation"
+ );
+ });
+}
+
/// Asynchronously walks a directory tree iteratively (without recursion).
/// Returns all entries with directories listed after their contents to enable
/// safe deletion (contents before containers).
diff --git a/core/server_common/src/iobuf.rs b/core/server_common/src/iobuf.rs
index 2337a81..8f97b86 100644
--- a/core/server_common/src/iobuf.rs
+++ b/core/server_common/src/iobuf.rs
@@ -125,6 +125,10 @@
self.inner.extend_from_slice(bytes);
}
+ pub fn truncate(&mut self, len: usize) {
+ self.inner.truncate(len);
+ }
+
pub fn split_at(self, split_at: usize) -> (Prefix<ALIGN>, Frozen<ALIGN>) {
assert!(split_at <= self.inner.len());
diff --git a/core/server_common/src/segment_storage/messages_reader.rs b/core/server_common/src/segment_storage/messages_reader.rs
index 6c07e45..f770571 100644
--- a/core/server_common/src/segment_storage/messages_reader.rs
+++ b/core/server_common/src/segment_storage/messages_reader.rs
@@ -40,14 +40,17 @@
.error(|e: &std::io::Error| format!("Failed to open messages file: {file_path}. {e}"))
.map_err(|_| IggyError::CannotReadFile)?;
- trace!("Validated messages file for reading: {file_path}");
-
- Ok(Self {
- file_path: file_path.to_string(),
- })
+ Ok(Self::from_validated_path(file_path))
}
pub fn path(&self) -> String {
self.file_path.clone()
}
+
+ pub(super) fn from_validated_path(file_path: &str) -> Self {
+ trace!("Validated messages file for reading: {file_path}");
+ Self {
+ file_path: file_path.to_owned(),
+ }
+ }
}
diff --git a/core/server_common/src/segment_storage/mod.rs b/core/server_common/src/segment_storage/mod.rs
index e88d90f..d389f6c 100644
--- a/core/server_common/src/segment_storage/mod.rs
+++ b/core/server_common/src/segment_storage/mod.rs
@@ -21,8 +21,11 @@
mod messages_writer;
use iggy_common::IggyError;
+use std::path::Path;
use std::rc::Rc;
+use crate::fs_utils::preallocate_file;
+
pub use index_reader::IndexReader;
pub use index_writer::IndexWriter;
pub use messages_reader::MessagesReader;
@@ -39,6 +42,58 @@
}
impl SegmentStorage {
+ /// The WAL owns message writes; this storage exposes only their committed prefix.
+ pub async fn with_read_only_messages(
+ messages_path: &str,
+ index_path: &str,
+ indexes_size: u64,
+ file_exists: bool,
+ preallocate_size: Option<u64>,
+ ) -> Result<Self, IggyError> {
+ let messages_reader = if file_exists && preallocate_size.is_none() {
+ MessagesReader::new(messages_path).await?
+ } else {
+ let messages_file = compio::fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(!file_exists)
+ .truncate(false)
+ .open(messages_path)
+ .await
+ .map_err(|_| IggyError::CannotCreateSegmentLogFile(messages_path.to_owned()))?;
+ let mut changed = !file_exists;
+ if let Some(size) = preallocate_size
+ && messages_file
+ .metadata()
+ .await
+ .map_err(|_| IggyError::CannotReadFileMetadata)?
+ .len()
+ == 0
+ {
+ preallocate_file(&messages_file, Path::new(messages_path), size);
+ changed = true;
+ }
+ if changed {
+ messages_file
+ .sync_all()
+ .await
+ .map_err(|_| IggyError::CannotSyncFile)?;
+ }
+ MessagesReader::from_validated_path(messages_path)
+ };
+ let indexes_size = Rc::new(std::sync::atomic::AtomicU64::new(indexes_size));
+ let index_writer = Rc::new(IndexWriter::new(index_path, indexes_size, file_exists).await?);
+ if file_exists {
+ index_writer.fsync().await?;
+ }
+ Ok(Self {
+ messages_writer: None,
+ messages_reader: Some(Rc::new(messages_reader)),
+ index_writer: Some(index_writer),
+ index_reader: Some(Rc::new(IndexReader::new(index_path).await?)),
+ })
+ }
+
pub async fn new(
messages_path: &str,
index_path: &str,
diff --git a/core/server_common/src/send_messages.rs b/core/server_common/src/send_messages.rs
index c0a51eb..436defa 100644
--- a/core/server_common/src/send_messages.rs
+++ b/core/server_common/src/send_messages.rs
@@ -519,10 +519,10 @@
let header_size = std::mem::size_of::<RoutedRequestHeader>();
let total_size = header_size + batch.header.total_size();
request_header.size = u32::try_from(total_size).map_err(|_| IggyError::InvalidCommand)?;
- let mut buffer = Owned::<MESSAGE_ALIGN>::zeroed(total_size);
+ let mut buffer = Owned::<MESSAGE_ALIGN>::with_capacity(total_size);
+ buffer.extend_from_slice(bytemuck::bytes_of(&request_header));
+ buffer.extend_from_slice(batch_bytes);
let bytes = buffer.as_mut_slice();
- bytes[0..header_size].copy_from_slice(bytemuck::bytes_of(&request_header));
- bytes[header_size..total_size].copy_from_slice(batch_bytes);
// The producer hashed `partition_id = 0`; stamp the resolved partition
// and restamp (or clear, for the stamp-fills-it path) the batch checksum.
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index ae32c22..e492a8f 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -372,7 +372,8 @@
/// consumer_offsets_max`, and `TransientNotAccepted` when the auto-commit
/// could not be submitted: the owning shard's inbox was full, or the
/// partition changed primary or incarnation during the read. Transient
- /// refusal permits re-polling. A capacity refusal needs a slot reclaimed
+ /// refusal also covers any read while the partition requires state transfer,
+ /// and permits retrying. A capacity refusal needs a slot reclaimed
/// or a higher configured limit before a new key can succeed.
Rejected(IggyError),
/// Reply to [`PartitionRead::GroupOffsetState`]: the group's last-polled and
@@ -468,7 +469,7 @@
/// `vec[i]` necessarily reaches shard `i`. The second receiver is the reply
/// lane: cross-shard client `Reply` forwards, whose drops are terminal, ride
/// a channel of their own so a consensus burst filling the main lane cannot
-/// evict them (see `[system.sharding] reply_inbox_capacity`).
+/// evict them (see `[sharding] reply_inbox_capacity`).
#[must_use]
pub fn shard_channel(
owner_shard: u16,
@@ -671,27 +672,40 @@
/// The `fd` is an owning [`DupedFd`] so that a frame dropped
/// unprocessed (shutdown, pump drain abort, router panic before
/// `install_*_fd`) closes the dup instead of leaking it.
- ReplicaInboundSetup { fd: DupedFd, slot: u64 },
+ ReplicaInboundSetup {
+ fd: DupedFd,
+ slot: u64,
+ },
/// Shard 0 dialed the higher-id peer `replica_id` and delegates the
/// raw connection; the receiving shard runs the dialer handshake
/// half, installs on success, and answers shard 0 with
/// [`LifecycleFrame::ReplicaOutboundHandshakeDone`] so the
/// pending-dial entry clears and the reconnect sweep may redial on
/// failure.
- ReplicaOutboundSetup { fd: DupedFd, replica_id: u8 },
+ ReplicaOutboundSetup {
+ fd: DupedFd,
+ replica_id: u8,
+ },
/// Owning shard -> shard 0: a delegated inbound handshake finished
/// (any outcome). Releases the global in-flight cap slot. Lost acks
/// are covered by the slot's deadline expiry on shard 0.
- ReplicaInboundHandshakeDone { slot: u64 },
+ ReplicaInboundHandshakeDone {
+ slot: u64,
+ },
/// Owning shard -> shard 0: a delegated outbound handshake finished
/// (any outcome). Clears the pending-dial entry for `replica_id`.
/// Lost acks are covered by the entry's deadline expiry on shard 0.
- ReplicaOutboundHandshakeDone { replica_id: u8 },
+ ReplicaOutboundHandshakeDone {
+ replica_id: u8,
+ },
/// Shard 0 distributes an inbound SDK client TCP connection fd to the
/// owning shard. The receiving shard wraps the fd and installs client
/// reader / writer tasks locally. The owning shard is encoded in the top
/// 16 bits of `meta.client_id`.
- ClientConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
+ ClientConnectionSetup {
+ fd: DupedFd,
+ meta: ClientConnMeta,
+ },
/// Shard 0 distributes an inbound SDK WebSocket client's pre-upgrade
/// TCP connection fd to the owning shard. The HTTP-Upgrade handshake
/// has NOT run yet at this point: the fd is plain TCP, the dup is
@@ -710,7 +724,10 @@
/// state is non-serialisable and tied to the endpoint's reactor.
/// Shard 0 therefore terminates QUIC locally and uses the existing
/// `ForwardClientSend` variant for outbound traffic.
- ClientWsConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
+ ClientWsConnectionSetup {
+ fd: DupedFd,
+ meta: ClientConnMeta,
+ },
/// A non-owning shard forwards a replica send to the owning shard's
/// local bus; the owning shard then takes the fast path.
ForwardReplicaSend {
@@ -719,7 +736,10 @@
},
/// A shard that doesn't hold the client's TCP connection forwards a
/// client send to the owning shard (top 16 bits of `client_id`).
- ForwardClientSend { client_id: u128, msg: BusMessage },
+ ForwardClientSend {
+ client_id: u128,
+ msg: BusMessage,
+ },
/// A peer shard hands a metadata consensus submit (login/logout) to
/// shard 0, the metadata consensus owner. The committed op returns over
/// the `reply` sender carried in [`MetadataSubmit`]. Always addressed to
@@ -762,6 +782,7 @@
/// the per-shard reconciler. No payload: reconciler re-reads target
/// state. Drops covered by the periodic safety tick.
MetadataCommitTick,
+ PartitionPersistenceCompleted(partitions::PersistenceCompletion),
/// Wake marker for the reconciler-to-pump funnel. Pump drains the
/// shard's `reconcile_queue` on receipt; tail drain on every frame
/// catches dropped markers.
@@ -1062,7 +1083,7 @@
/// SDK batch types), so the largest appendable batch is whatever the message bus
/// will frame. This tracks the shipped `message_bus.max_message_size` default; an
/// operator raising that is caught by the config validator, which requires
-/// `partition.transfer_artifact_bytes_max` to cover `system.segment.size` plus
+/// `partition.transfer_artifact_bytes_max` to cover the topic's `segment_size` plus
/// the configured bus cap.
const SEGMENT_SIZE_OVERSHOOT_BYTES: u64 = 64 * 1024 * 1024;
@@ -4081,6 +4102,8 @@
// while `partitions` builds as a plain dependency, so `RetainedPartitionLog`
// and `adopt_retained_log` are configured out and the crate does not compile.
// The feature forwards to `partitions/simulator` instead.
+ /// # Panics
+ /// Rejects persisted policies because this in-memory materializer has no durable backend.
#[cfg(feature = "simulator")]
pub fn init_partition(
&self,
@@ -4093,6 +4116,7 @@
) where
B: MessageBus + Clone + 'static,
T: ShardsTable,
+ M: metadata::impls::metadata::StreamsFrontend,
{
let PartitionMaterialisation {
epoch,
@@ -4156,8 +4180,23 @@
stats,
consensus,
partitions.config().segment_size,
- partitions.config().consumer_offset_enforce_fsync,
);
+ let runtime_options = self.plane.metadata().mux_stm.streams().read(|inner| {
+ inner
+ .items
+ .get(namespace.stream_id())
+ .and_then(|stream| stream.topics.get(namespace.topic_id()))
+ .map(|topic| {
+ iggy_common::TopicRuntimeOptions::from_resource_options(&topic.options)
+ })
+ .unwrap_or_default()
+ });
+ assert!(
+ !runtime_options.durability.is_persisted()
+ && !runtime_options.consumer_offset_durability.is_persisted(),
+ "the in-memory partition simulator does not implement persisted topics. Use storage fault-model tests or the real-server harness"
+ );
+ partition.set_runtime_options(runtime_options);
partition.set_consumer_offsets_max(consumer_offsets_max);
if let Some(superblock) = superblock {
partition.set_superblock(superblock, recovered_state.as_ref());
@@ -4296,10 +4335,7 @@
// executes there (`dispatch_vsr_actions` bails on `journal: None`)
// and `CommitJournal` is a no-op in both.
dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions).await;
}
#[allow(clippy::future_not_send)]
@@ -4373,10 +4409,7 @@
// executes there (`dispatch_vsr_actions` bails on `journal: None`)
// and `CommitJournal` is a no-op in both.
dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions).await;
// Outside the gate: the persist fences the SEND, not the local commit
// walk (state a crash forgets is state no peer ever saw). Same
// transfer gate as the metadata arm: no walk while transferring.
@@ -4527,6 +4560,7 @@
);
return;
};
+ partition.ensure_materialization_recovery();
let actions =
partition
.consensus()
@@ -4550,10 +4584,7 @@
// executes there (`dispatch_vsr_actions` bails on `journal: None`)
// and `CommitJournal` is a no-op in both.
dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions).await;
// Gate on actual adoption: a rejected StartView returns no actions,
// and re-arming on one would re-mint the nonce and drop an in-flight
// descriptor.
@@ -4689,7 +4720,9 @@
// advertises this replica's current view. Withhold on failure;
// the stale peer keeps heartbeating, so it re-triggers once a
// later persist succeeds.
- if partition.persist_superblock_if_needed().await {
+ if !partition.requires_state_transfer()
+ && partition.persist_superblock_if_needed().await
+ {
respond_start_view::<B, _, MJ>(consensus).await;
}
}
@@ -4740,10 +4773,7 @@
// dispatcher owns SendPrepareOk and the debug durable-before-send
// tripwire, and skipping it would drop both silently the day this
// handler emits one.
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions).await;
}
/// Serve a repair range from this replica's journal: stream
@@ -5741,10 +5771,8 @@
// view, so the `StartView` it emits advertises a view the superblock
// must already record. Same gate as the `on_do_view_change` and
// `on_start_view` partition arms.
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions)
+ .await;
local_actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
@@ -7430,6 +7458,25 @@
futures::future::join_all(chunk).await;
}
+ let mut persistence_metrics = partitions::PersistenceMetrics::default();
+ for namespace in namespace_scratch.iter() {
+ if let Some(partition) = partitions.get_mut_by_ns(namespace) {
+ partition.drive_persistence().await;
+ if let Some(metrics) = partition.take_persistence_metrics() {
+ persistence_metrics.disk_bytes += metrics.disk_bytes;
+ persistence_metrics.retained_bytes += metrics.retained_bytes;
+ persistence_metrics.queued_bytes += metrics.queued_bytes;
+ persistence_metrics.in_flight_bytes += metrics.in_flight_bytes;
+ persistence_metrics.checkpoints_pending += metrics.checkpoints_pending;
+ persistence_metrics.completed_batches += metrics.completed_batches;
+ persistence_metrics.batched_prepares += metrics.batched_prepares;
+ persistence_metrics.completed_checkpoints += metrics.completed_checkpoints;
+ persistence_metrics.failed_writes += metrics.failed_writes;
+ }
+ }
+ }
+ self.metrics.record_persistence(&persistence_metrics);
+
// Counted at most ONCE per sweep and only if a re-arm actually fires,
// then tracked locally as arms land. Counting per namespace is a full
// scan per partition, so with per-partition groups the sweep would be
@@ -7519,7 +7566,9 @@
if consensus.status() != Status::Normal {
refresh_partition_dvc_suffix(partition);
}
+ partition.ensure_materialization_recovery();
let actions = consensus.tick(PlaneKind::Partitions);
+ partition.ensure_materialization_recovery();
// The tick emits view-scoped sends (heartbeats, view-change
// retransmits), so it persists first like every dispatch site;
// it is also what retries a persist an earlier site withheld on.
@@ -7527,10 +7576,8 @@
// Locals to the partition dispatcher only; see the view-change
// sites for the rationale.
dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
- if partition.persist_superblock_if_needed().await {
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
- dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
- }
+ dispatch_partition_wire_actions::<B, _, MJ, _>(consensus, partition, wire_actions)
+ .await;
// Finish a view change whose quorum decided ahead of the local log.
self.advance_pending_partition_view(namespace).await;
@@ -7553,6 +7600,20 @@
walk_cursor.get_or_insert(namespace);
}
}
+ if partition.needs_persistence_checkpoint() {
+ if walks < PARTITION_WALKS_PER_TICK_MAX {
+ walks += 1;
+ partition.checkpoint_persistence(partitions.config()).await;
+ } else {
+ walk_cursor.get_or_insert(namespace);
+ }
+ }
+ if let Some(fault) = partition.fatal() {
+ if fatal.is_none() {
+ fatal = Some(fault.clone());
+ }
+ continue;
+ }
let consensus_view = partition.consensus().view();
let commit_min = partition.consensus().commit_min();
let cluster = partition.consensus().cluster();
@@ -8023,7 +8084,7 @@
/// passes, so admission cannot revoke a transfer midway.
///
/// BOTH inputs are the configured ones. Dividing by the compile-time
- /// segment ceiling instead of the deployed `system.segment.size` would make
+ /// segment ceiling instead of the deployed the topic's `segment_size` would make
/// the numerator the only thing an operator controls: on a 64 MiB-segment
/// deployment the same budget holds sixteen times as many payloads as a cap
/// derived from the 1 GiB ceiling would admit, and rejoins serialise for no
@@ -8501,11 +8562,10 @@
/// disk as they complete, so this bounds corruption, not memory.
const PARTITION_TRANSFER_TOTAL_LEN_MAX: u64 = 1 << 40;
- /// Alloc cap for the `CONSUMER_OFFSETS` artifact, which accumulates whole
- /// in `ArtifactProgress::buf` before decode can reject it. Its decoder
- /// ceilings imply ~24 MiB (two sections of 2^20 12-byte entries); this
- /// leaves headroom without letting a hostile manifest stage gigabytes.
- const CONSUMER_OFFSETS_ARTIFACT_LEN_MAX: u64 = 32 << 20;
+ /// Bound the buffered offset and dedup state plus one maximum-sized
+ /// checkpoint prepare and its length prefix before allocating the artifact.
+ const CONSUMER_OFFSETS_ARTIFACT_LEN_MAX: u64 =
+ (32 << 20) + journal::partition_journal::PREPARE_BYTES_MAX as u64 + 4;
/// Concurrent partition transfers this shard will run as a RECEIVER. A
/// whole-node rejoin arms one per lagging partition; unbounded, the sum
@@ -8835,6 +8895,16 @@
else {
return false;
};
+ // A resident window may only be waiting for persistence or a bounded commit walk.
+ if partition
+ .log
+ .journal()
+ .inner
+ .repaired_window_shape(consensus.commit_min(), fetch_to_op)
+ .complete
+ {
+ return false;
+ }
let nonce = iggy_common::random_id::get_uuid();
let from_op = consensus.commit_min() + 1;
let cluster = consensus.cluster();
@@ -10191,6 +10261,7 @@
from_op: u64,
to_op: u64,
header_at: impl Fn(u64) -> Option<PrepareHeader>,
+ local_ack: impl Fn(&PrepareHeader) -> bool,
) where
B: MessageBus,
P: Pipeline<Entry = consensus::PipelineEntry>,
@@ -10206,7 +10277,9 @@
// post-view-change prepares cannot stamp below committed ones.
consensus.observe_prepare_timestamp(header.timestamp);
let mut entry = consensus::PipelineEntry::new(header);
- entry.add_ack(self_id);
+ if local_ack(&header) {
+ entry.add_ack(self_id);
+ }
Some(entry)
})
.collect();
@@ -10559,7 +10632,7 @@
///
/// Same correlated-fan-out argument as the repair arm, and the walk is the
/// costlier half: `commit_journal` reaches `commit_messages`, which flushes a
-/// segment and fsyncs under `enforce_fsync`.
+/// segment and synchronizes it under `durability=persisted`.
///
/// The two caps together are what bound the tick: this one bounds how many
/// groups a sweep walks, [`partitions::COMMIT_WALK_OPS_MAX`] bounds how far
@@ -11514,12 +11587,19 @@
let Some(journal) = journal else {
continue;
};
- rebuild_pipeline_entries(consensus, self_id, *from_op, *to_op, |op| {
- usize::try_from(op)
- .ok()
- .and_then(|slot| journal.handle().header(slot))
- .map(|header| *header)
- });
+ rebuild_pipeline_entries(
+ consensus,
+ self_id,
+ *from_op,
+ *to_op,
+ |op| {
+ usize::try_from(op)
+ .ok()
+ .and_then(|slot| journal.handle().header(slot))
+ .map(|header| *header)
+ },
+ |_| true,
+ );
}
// Handled by the caller (shard view change handlers) since it
// requires access to the plane's commit_journal method.
@@ -11549,6 +11629,28 @@
}
}
+#[allow(clippy::future_not_send)]
+async fn dispatch_partition_wire_actions<B, P, J, SB>(
+ consensus: &VsrConsensus<B, P>,
+ partition: &IggyPartition<B, SB>,
+ mut actions: Vec<VsrAction>,
+) where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+ J: JournalHandle,
+ J::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ SB: SuperblockStore,
+{
+ if !partition.persist_superblock_if_needed().await {
+ return;
+ }
+ if partition.requires_state_transfer() {
+ actions.retain(|action| matches!(action, VsrAction::SendRequestStartView { .. }));
+ }
+ dispatch_vsr_actions::<B, P, J>(consensus, None, &actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &actions).await;
+}
+
#[allow(
clippy::future_not_send,
clippy::too_many_lines,
@@ -11561,12 +11663,10 @@
) where
B: MessageBus,
P: Pipeline<Entry = consensus::PipelineEntry>,
+ SB: SuperblockStore,
{
- use std::mem::size_of;
-
let bus = consensus.message_bus();
let self_id = consensus.replica();
- let cluster = consensus.cluster();
let journal = &partition.log.journal().inner;
let send = |target: u8, msg: Frozen<MESSAGE_ALIGN>| async move {
@@ -11597,44 +11697,16 @@
view,
from_op,
to_op,
- target,
- group,
+ ..
} => {
+ if *view != consensus.view() {
+ continue;
+ }
for op in *from_op..=*to_op {
- let Some(prepare_header) = journal.header_by_op(op) else {
- continue;
- };
- let msg = Message::<PrepareOkHeader>::new(size_of::<PrepareOkHeader>())
- .transmute_header(|_, h: &mut PrepareOkHeader| {
- h.command = Command::PrepareOk;
- h.cluster = cluster;
- h.replica = self_id;
- h.view = *view;
- h.op = op;
- h.commit = consensus.commit_max();
- h.timestamp = prepare_header.timestamp;
- h.parent = prepare_header.parent;
- h.prepare_checksum = prepare_header.checksum;
- h.request = prepare_header.request;
- h.operation = prepare_header.operation;
- h.group = *group;
- h.size = size_of::<PrepareOkHeader>() as u32;
- h.seal();
- });
- send(*target, msg.into_generic().into_frozen()).await;
+ partition.acknowledge_prepare(op).await;
}
}
VsrAction::RetransmitPrepares { targets } => {
- // DURABILITY CAVEAT: the only `Storage` impl on
- // `PartitionJournal` right now is the in-memory
- // `PartitionJournalMemStorage`. After a process restart
- // the journal is empty and every `journal.entry` below
- // returns `None`, so retransmit silently drops the
- // request and peers stall until a view change. The bus
- // and consensus plumbing is correct; only the storage
- // needs to become durable before cluster workloads go to
- // production. Server boot emits a loud warning to the
- // operator (see `main.rs`).
let current_view = consensus.view();
for (header, replicas) in targets {
let Some(prepare) = journal.entry(header).await else {
@@ -11656,9 +11728,14 @@
}
}
VsrAction::RebuildPipeline { from_op, to_op } => {
- rebuild_pipeline_entries(consensus, self_id, *from_op, *to_op, |op| {
- journal.header_by_op(op)
- });
+ rebuild_pipeline_entries(
+ consensus,
+ self_id,
+ *from_op,
+ *to_op,
+ |op| journal.header_by_op(op),
+ |header| partition.register_rebuilt_ack(header),
+ );
}
_ => {}
}
@@ -13131,3 +13208,233 @@
assert!(!repair_chunk_walked(5, 5, 12));
}
}
+
+#[cfg(test)]
+mod partition_ack_durability_tests {
+ use super::*;
+ use consensus::LocalPipeline;
+ use iggy_common::PartitionStats;
+ use iggy_common::{Durability, IggyByteSize, TopicRuntimeOptions};
+ use journal::prepare_journal::PrepareJournal;
+ use message_bus::IggyMessageBus;
+ use server_common::iobuf::Owned;
+ use std::sync::Arc;
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+ #[compio::test]
+ #[allow(clippy::too_many_lines)]
+ async fn ordinary_start_view_replies_do_not_turn_missing_bodies_into_canonical_headers() {
+ let bus = IggyMessageBus::new(0);
+ let sent = Rc::new(RefCell::new(Vec::new()));
+ let captured = sent.clone();
+ bus.set_replica_forward_fn(Box::new(move |_, _, frame| {
+ captured.borrow_mut().push(frame);
+ Ok(())
+ }));
+ for replica in 1..3 {
+ assert!(bus.owner_table().try_claim(replica, 1));
+ }
+ let consensus = VsrConsensus::new(1, 0, 3, 42, bus, LocalPipeline::new());
+ consensus.init();
+ let partition: Box<IggyPartition<IggyMessageBus>> =
+ Box::new(IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ ));
+ let mut headers: Vec<PrepareHeader> = Vec::new();
+ for op in 1..=2 {
+ let prepare = Message::<PrepareHeader>::new(size_of::<PrepareHeader>())
+ .transmute_header(|_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::StoreConsumerOffset;
+ header.cluster = 1;
+ header.group = 42;
+ header.op = op;
+ header.parent = headers.last().map_or(0, |previous| previous.checksum);
+ header.timestamp = op;
+ header.size = u32::try_from(size_of::<PrepareHeader>()).unwrap();
+ header.checksum = header.identity_checksum();
+ });
+ headers.push(*prepare.header());
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.into_frozen())
+ .await
+ .unwrap();
+ }
+ let consensus = partition.consensus();
+ consensus.sequencer().set_sequence(2);
+ consensus.advance_commit_max(1);
+ let probe = Message::<RequestStartViewHeader>::new(size_of::<RequestStartViewHeader>())
+ .transmute_header(|_, header: &mut RequestStartViewHeader| {
+ header.command = Command::RequestStartView;
+ header.cluster = 1;
+ header.replica = 1;
+ header.group = 42;
+ header.size = u32::try_from(size_of::<RequestStartViewHeader>()).unwrap();
+ header.seal();
+ });
+ let actions = consensus.handle_request_start_view(PlaneKind::Partitions, probe.header());
+ dispatch_partition_wire_actions::<_, _, PrepareJournal, _>(consensus, &partition, actions)
+ .await;
+ assert_eq!(
+ sent.borrow().len(),
+ 1,
+ "probe reply is addressed to its requester"
+ );
+ respond_start_view::<_, _, PrepareJournal>(consensus).await;
+ assert_eq!(
+ sent.borrow().len(),
+ 3,
+ "stale-view correction reaches both backups"
+ );
+ let backup = Box::new(VsrConsensus::new(
+ 1,
+ 1,
+ 3,
+ 42,
+ IggyMessageBus::new(0),
+ LocalPipeline::new(),
+ ));
+ backup.init();
+ for frame in sent.borrow().iter() {
+ let header_size = size_of::<StartViewHeader>();
+ assert_eq!(frame.len(), header_size);
+ let message =
+ Message::<StartViewHeader>::try_from(Owned::copy_from_slice(frame.as_slice()))
+ .unwrap();
+ backup.handle_start_view(
+ PlaneKind::Partitions,
+ message.header(),
+ &message.as_slice()[header_size..],
+ );
+ assert!(!backup.view_log_is_pending());
+ let suffix = build_dvc_suffix(
+ backup.commit_max(),
+ backup.sequencer().current_sequence(),
+ |_| None,
+ None,
+ );
+ assert_eq!(
+ suffix.nack_bitset(),
+ 1,
+ "the uncommitted body is still missing"
+ );
+ }
+ sent.borrow_mut().clear();
+ headers.reverse();
+ dispatch_partition_wire_actions::<_, _, PrepareJournal, _>(
+ consensus,
+ &partition,
+ vec![VsrAction::SendStartView {
+ view: 0,
+ op: 2,
+ commit: 1,
+ incarnation: 0,
+ target: Some(1),
+ group: 42,
+ suffix: headers,
+ }],
+ )
+ .await;
+ let frames = sent.borrow();
+ assert_eq!(frames.len(), 1);
+ let header_size = size_of::<StartViewHeader>();
+ let message =
+ Message::<StartViewHeader>::try_from(Owned::copy_from_slice(frames[0].as_slice()))
+ .unwrap();
+ consensus::dvc_suffix_decode(&message.as_slice()[header_size..], 2, 0, 0).unwrap();
+ backup.handle_start_view(
+ PlaneKind::Partitions,
+ message.header(),
+ &message.as_slice()[header_size..],
+ );
+ assert!(
+ backup.view_log_is_pending(),
+ "a merge-concluding suffix still reaches the backup"
+ );
+ }
+
+ #[compio::test]
+ async fn start_view_ack_waits_for_partition_wal_completion() {
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let directory = std::env::temp_dir().join(format!(
+ "iggy-start-view-wal-{}-{unique}",
+ std::process::id()
+ ));
+ std::fs::create_dir(&directory).unwrap();
+ let consensus =
+ VsrConsensus::new(1, 0, 3, 42, IggyMessageBus::new(0), LocalPipeline::new());
+ consensus.init();
+ consensus.mark_superblock_durable(0, 0);
+ let mut partition: IggyPartition<IggyMessageBus> = IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ );
+ partition.set_partition_dir(directory.to_string_lossy().into_owned());
+ partition.set_runtime_options(TopicRuntimeOptions {
+ consumer_offset_durability: Durability::Persisted,
+ ..TopicRuntimeOptions::default()
+ });
+ partition.open_persistence().await.unwrap();
+ let prepare = Message::<PrepareHeader>::new(size_of::<PrepareHeader>()).transmute_header(
+ |_, header: &mut PrepareHeader| {
+ header.command = Command::Prepare;
+ header.operation = Operation::StoreConsumerOffset;
+ header.cluster = 1;
+ header.group = 42;
+ header.op = 1;
+ header.size = u32::try_from(size_of::<PrepareHeader>()).unwrap();
+ header.checksum = header.identity_checksum();
+ },
+ );
+ partition
+ .log
+ .journal()
+ .inner
+ .append(prepare.into_frozen())
+ .await
+ .unwrap();
+ partition.consensus().sequencer().set_sequence(1);
+ dispatch_partition_journal_actions(
+ partition.consensus(),
+ &partition,
+ &[VsrAction::SendPrepareOk {
+ view: 0,
+ from_op: 1,
+ to_op: 1,
+ target: 0,
+ group: 42,
+ }],
+ )
+ .await;
+ let mut acknowledgments = Vec::new();
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ assert!(
+ acknowledgments.is_empty(),
+ "StartView must not bypass the WAL barrier"
+ );
+ for _ in 0..100 {
+ compio::runtime::time::sleep(Duration::from_millis(10)).await;
+ partition.drive_persistence().await;
+ partition
+ .consensus()
+ .drain_loopback_into(&mut acknowledgments);
+ if !acknowledgments.is_empty() {
+ break;
+ }
+ }
+ assert_eq!(acknowledgments.len(), 1);
+ drop(partition);
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+}
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index b504137..01569de 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -34,7 +34,7 @@
//! compio reactor contexts. Each shard owns its own instance, and the server
//! exposes every shard's instance through the `[http.metrics]` scrape
//! endpoint via [`ShardMetrics::register`] (one `shard`-labelled
-//! sub-registry per shard); every drop site also logs via `tracing`.
+//! sub-registry per shard). Drop-site tracing supplements the counters.
use prometheus_client::encoding::EncodeLabelSet;
use prometheus_client::metrics::counter::Counter;
@@ -113,6 +113,7 @@
/// status and no frame of the client's was dropped, so counting it with
/// shed frames would read as a routing loss.
pub const PARTITION_AUTO_COMMIT: &str = "partition_auto_commit";
+ pub const PARTITION_PERSISTENCE_COMPLETED: &str = "partition_persistence_completed";
}
/// Reason labels used in `frame_drops_total`.
@@ -161,7 +162,7 @@
// pair enters the `Family` (and therefore the scrape) the first time a drop
// site actually produces it, so the unreachable corners of the 7 x 9 cross
// product never appear as permanent zero-valued series.
-const VARIANT_COUNT: usize = 8;
+const VARIANT_COUNT: usize = 9;
const REASON_COUNT: usize = 11;
const VARIANTS: [&str; VARIANT_COUNT] = [
@@ -173,6 +174,7 @@
frame_drop_variant::METADATA_COMMIT_TICK,
frame_drop_variant::REPLICA_HANDSHAKE_ACK,
frame_drop_variant::PARTITION_AUTO_COMMIT,
+ frame_drop_variant::PARTITION_PERSISTENCE_COMPLETED,
];
const REASONS: [&str; REASON_COUNT] = [
@@ -222,6 +224,15 @@
/// resolved at scrape time via the per-shard registry, not as a label.
#[derive(Clone)]
pub struct ShardMetrics {
+ partition_wal_disk_bytes: Gauge,
+ partition_wal_retained_bytes: Gauge,
+ partition_wal_queued_bytes: Gauge,
+ partition_wal_in_flight_bytes: Gauge,
+ partition_wal_checkpoints_pending: Gauge,
+ partition_wal_batches: Counter,
+ partition_wal_prepares: Counter,
+ partition_wal_checkpoints: Counter,
+ partition_wal_errors: Counter,
frame_drops_total: Family<FrameDropLabel, Counter>,
cached_counters: Arc<[[OnceLock<Counter>; REASON_COUNT]; VARIANT_COUNT]>,
partitions_materialised_total: Counter,
@@ -282,6 +293,15 @@
.clone();
let consumer_offset_stranded_gauges = [consumer_stranded, group_stranded];
Self {
+ partition_wal_disk_bytes: Gauge::default(),
+ partition_wal_retained_bytes: Gauge::default(),
+ partition_wal_queued_bytes: Gauge::default(),
+ partition_wal_in_flight_bytes: Gauge::default(),
+ partition_wal_checkpoints_pending: Gauge::default(),
+ partition_wal_batches: Counter::default(),
+ partition_wal_prepares: Counter::default(),
+ partition_wal_checkpoints: Counter::default(),
+ partition_wal_errors: Counter::default(),
frame_drops_total,
cached_counters,
partitions_materialised_total: Counter::default(),
@@ -304,6 +324,72 @@
}
}
+ pub fn record_persistence(&self, metrics: &partitions::PersistenceMetrics) {
+ self.partition_wal_disk_bytes
+ .set(i64::try_from(metrics.disk_bytes).unwrap_or(i64::MAX));
+ self.partition_wal_retained_bytes
+ .set(i64::try_from(metrics.retained_bytes).unwrap_or(i64::MAX));
+ self.partition_wal_queued_bytes
+ .set(i64::try_from(metrics.queued_bytes).unwrap_or(i64::MAX));
+ self.partition_wal_in_flight_bytes
+ .set(i64::try_from(metrics.in_flight_bytes).unwrap_or(i64::MAX));
+ self.partition_wal_checkpoints_pending
+ .set(i64::try_from(metrics.checkpoints_pending).unwrap_or(i64::MAX));
+ self.partition_wal_batches.inc_by(metrics.completed_batches);
+ self.partition_wal_prepares.inc_by(metrics.batched_prepares);
+ self.partition_wal_checkpoints
+ .inc_by(metrics.completed_checkpoints);
+ self.partition_wal_errors.inc_by(metrics.failed_writes);
+ }
+
+ fn register_persistence(&self, registry: &mut Registry) {
+ registry.register(
+ "partition_wal_disk_bytes",
+ "active partition WAL bytes",
+ self.partition_wal_disk_bytes.clone(),
+ );
+ registry.register(
+ "partition_wal_retained_bytes",
+ "retained partition prepare bytes charged to the WAL budget",
+ self.partition_wal_retained_bytes.clone(),
+ );
+ registry.register(
+ "partition_wal_queued_bytes",
+ "queued partition WAL bytes",
+ self.partition_wal_queued_bytes.clone(),
+ );
+ registry.register(
+ "partition_wal_in_flight_bytes",
+ "partition WAL bytes being written",
+ self.partition_wal_in_flight_bytes.clone(),
+ );
+ registry.register(
+ "partition_wal_checkpoints_pending",
+ "partition checkpoints awaiting storage completion",
+ self.partition_wal_checkpoints_pending.clone(),
+ );
+ registry.register(
+ "partition_wal_batches",
+ "completed partition WAL durability batches",
+ self.partition_wal_batches.clone(),
+ );
+ registry.register(
+ "partition_wal_prepares",
+ "prepares covered by completed partition WAL batches",
+ self.partition_wal_prepares.clone(),
+ );
+ registry.register(
+ "partition_wal_checkpoints",
+ "completed partition WAL checkpoints",
+ self.partition_wal_checkpoints.clone(),
+ );
+ registry.register(
+ "partition_wal_errors",
+ "partition WAL writer failures",
+ self.partition_wal_errors.clone(),
+ );
+ }
+
/// Best effort: counts explicit client denials read off the reply status
/// and the poll-side reservation refusals. A denial the pump answers to an
/// auto-commit submit has no client reply to read and is not counted.
@@ -636,6 +722,7 @@
/// `_total` suffix; the prometheus text exposition appends it for
/// counters.
pub fn register(&self, registry: &mut Registry) {
+ self.register_persistence(registry);
registry.register(
"frame_drops",
"frames shed instead of delivered, by frame class and refusal reason",
@@ -758,6 +845,30 @@
}
#[test]
+ fn persistence_scrape_distinguishes_retained_budget_from_wal_file_bytes() {
+ let metrics = ShardMetrics::for_shard();
+ metrics.record_persistence(&partitions::PersistenceMetrics {
+ disk_bytes: 4096,
+ retained_bytes: 65536,
+ ..Default::default()
+ });
+ let mut registry = Registry::default();
+ metrics.register(&mut registry);
+ let mut buffer = String::new();
+ prometheus_client::encoding::text::encode(&mut buffer, ®istry).unwrap();
+ assert!(
+ buffer
+ .lines()
+ .any(|line| line == "partition_wal_disk_bytes 4096")
+ );
+ assert!(
+ buffer
+ .lines()
+ .any(|line| line == "partition_wal_retained_bytes 65536")
+ );
+ }
+
+ #[test]
fn unproduced_pairs_never_enter_the_scrape() {
// The lazy fast-path cache must not mint the full variant x reason
// cross product: a pair no drop site produced would otherwise sit in
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 1e46ec9..0eea8a6 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -31,6 +31,7 @@
use server_common::sharding::{IggyNamespace, METADATA_GROUP};
use server_common::{Message, MessageBag};
use std::future::poll_fn;
+use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::Poll;
@@ -285,6 +286,20 @@
Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: RestorableMetadataStm,
{
+ if let Some(sender) = self.senders.get(self.id as usize).cloned() {
+ let metrics = self.metrics.clone();
+ self.plane
+ .partitions()
+ .set_persistence_notifier(Rc::new(move |completion| {
+ let frame = LifecycleFrame::PartitionPersistenceCompleted(completion);
+ if let Err(error) = sender.try_send(ShardFrame::lifecycle(frame)) {
+ metrics.record_frame_drop(
+ frame_drop_variant::PARTITION_PERSISTENCE_COMPLETED,
+ crate::coordinator::classify_try_send_err(&error),
+ );
+ }
+ }));
+ }
// Reused across every pump iteration; pre-size to skip the
// first-drain reallocation.
let mut loopback_buf = Vec::with_capacity(64);
@@ -763,6 +778,12 @@
);
}
}
+ LifecycleFrame::PartitionPersistenceCompleted(completion) => {
+ let namespace = IggyNamespace::from_raw(completion.group);
+ if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
+ partition.on_persistence_completed(completion).await;
+ }
+ }
LifecycleFrame::ReconcileApply => {
self.apply_reconcile_ops();
}
diff --git a/core/simulator/Cargo.toml b/core/simulator/Cargo.toml
index 0ac51b6..0abdfc7 100644
--- a/core/simulator/Cargo.toml
+++ b/core/simulator/Cargo.toml
@@ -56,6 +56,7 @@
# ships. See `partitions/Cargo.toml`.
partitions = { workspace = true, features = ["fault-injection"] }
tempfile = { workspace = true }
+twox-hash = { workspace = true }
[lints.clippy]
enum_glob_use = "deny"
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 3fd111c..0b25090 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -24,6 +24,7 @@
pub mod ready_queue;
pub mod replica;
pub mod seeds;
+pub mod storage;
pub mod workload;
use bus::SimOutbox;
@@ -1680,7 +1681,7 @@
use iggy_common::ConsumerKind;
use server_common::sharding::IggyNamespace;
- fn submit_and_wait_for_reply(
+ pub fn submit_and_wait_for_reply(
sim: &mut Simulator,
client_id: u128,
target: u8,
@@ -2794,7 +2795,7 @@
.count()
}
- fn retained_prepare(
+ pub fn retained_prepare(
sim: &Simulator,
replica: usize,
namespace: IggyNamespace,
@@ -5485,9 +5486,11 @@
use bytes::Bytes;
use consensus::Status;
use iggy_binary_protocol::{
- CommitHeader, PrepareHeader, RepairRangeReplyHeader, RequestPreparesHeader,
+ CommitHeader, ConsensusHeader, PrepareHeader, RepairRangeReplyHeader,
+ RequestPreparesHeader, StartViewHeader,
};
use packet::Packet;
+ use server_common::MessageBag;
use std::sync::atomic::{AtomicU64, Ordering};
/// Chain replication runs 0 -> 1 -> 2 and stops before the primary, so
@@ -5718,6 +5721,204 @@
}
#[test]
+ fn given_a_resident_repair_window_when_commit_walk_is_bounded_should_drain_without_repair() {
+ const OPS: usize = partitions::COMMIT_WALK_OPS_MAX + 2;
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let sim = resident_repair_window(namespace, OPS, OPS);
+ let shard = sim.replicas[usize::from(LAGGING)].partition_shard(namespace);
+
+ deliver_commit(shard, namespace, OPS as u64);
+
+ let (_, _, commit_min, commit_max) = group_state(&sim, LAGGING, namespace);
+ assert_eq!(commit_min, partitions::COMMIT_WALK_OPS_MAX as u64);
+ assert_eq!(commit_max, OPS as u64);
+ for op in commit_min + 1..=commit_max {
+ assert!(journal_holds(&sim, LAGGING, namespace, op));
+ }
+ assert!(
+ shard
+ .plane
+ .partitions()
+ .get_by_ns(&namespace)
+ .unwrap()
+ .repair
+ .is_none(),
+ "resident operations must not consume a repair session while waiting for the next walk"
+ );
+ assert!(repair_requests(&sim).is_empty());
+
+ let mut namespace_scratch = Vec::new();
+ assert!(
+ futures::executor::block_on(shard.tick_partitions(&mut namespace_scratch)).is_none()
+ );
+ assert_eq!(
+ group_state(&sim, LAGGING, namespace),
+ (Status::Normal, 0, OPS as u64, OPS as u64),
+ "the existing tick must finish the resident backlog without peer repair"
+ );
+ assert!(repair_requests(&sim).is_empty());
+ }
+
+ #[test]
+ fn given_a_resident_prefix_when_a_later_op_is_missing_should_repair_and_drain() {
+ const RESIDENT_OPS: usize = partitions::COMMIT_WALK_OPS_MAX + 1;
+ const OPS: usize = RESIDENT_OPS + 1;
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let sim = resident_repair_window(namespace, OPS, RESIDENT_OPS);
+ let shard = sim.replicas[usize::from(LAGGING)].partition_shard(namespace);
+
+ deliver_commit(shard, namespace, OPS as u64);
+
+ let (_, _, commit_min, _) = group_state(&sim, LAGGING, namespace);
+ assert_eq!(commit_min, partitions::COMMIT_WALK_OPS_MAX as u64);
+ assert!(journal_holds(&sim, LAGGING, namespace, commit_min + 1));
+ assert!(!journal_holds(&sim, LAGGING, namespace, OPS as u64));
+ let requests = repair_requests(&sim);
+ assert_eq!(
+ requests.len(),
+ 1,
+ "a later hole must still open peer repair"
+ );
+ assert_eq!(requests[0].from_op, commit_min + 1);
+ assert_eq!(requests[0].to_op, OPS as u64);
+
+ let prepare = tests::retained_prepare(&sim, 0, namespace, OPS as u64);
+ let partition = shard.plane.partitions().get_mut_by_ns(&namespace).unwrap();
+ futures::executor::block_on(partition.apply_repaired_prepare(prepare));
+ assert!(journal_holds(&sim, LAGGING, namespace, OPS as u64));
+ let mut namespace_scratch = Vec::new();
+ assert!(
+ futures::executor::block_on(shard.tick_partitions(&mut namespace_scratch)).is_none()
+ );
+ assert_eq!(
+ group_state(&sim, LAGGING, namespace),
+ (Status::Normal, 0, OPS as u64, OPS as u64)
+ );
+ }
+
+ #[test]
+ fn given_a_resident_committed_window_when_an_adopted_suffix_is_missing_should_fetch_above_commit_max()
+ {
+ const COMMITTED_OPS: usize = partitions::COMMIT_WALK_OPS_MAX + 1;
+ const OPS: usize = COMMITTED_OPS + 1;
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let sim = resident_repair_window(namespace, OPS, COMMITTED_OPS);
+ let shard = sim.replicas[usize::from(LAGGING)].partition_shard(namespace);
+ let missing = tests::retained_prepare(&sim, 0, namespace, OPS as u64);
+ let header_size = size_of::<StartViewHeader>();
+ let total_size = header_size + size_of::<PrepareHeader>();
+ let mut start_view = Message::<StartViewHeader>::new(total_size);
+ start_view.as_mut_slice()[header_size..]
+ .copy_from_slice(bytemuck::bytes_of(missing.header()));
+ let body_checksum = u128::from(iggy_common::calculate_checksum(
+ &start_view.as_slice()[header_size..],
+ ));
+ let start_view = start_view.transmute_header(|_, header: &mut StartViewHeader| {
+ header.command = Command::StartView;
+ header.cluster = 1;
+ header.replica = 0;
+ header.group = namespace.inner();
+ header.op = OPS as u64;
+ header.commit = COMMITTED_OPS as u64;
+ header.size = u32::try_from(total_size).unwrap();
+ header.checksum_body = body_checksum;
+ header.seal();
+ });
+ futures::executor::block_on(shard.on_message(MessageBag::StartView(start_view)));
+
+ let (_, _, commit_min, commit_max) = group_state(&sim, LAGGING, namespace);
+ assert_eq!(commit_min, partitions::COMMIT_WALK_OPS_MAX as u64);
+ assert_eq!(commit_max, COMMITTED_OPS as u64);
+ assert!(journal_holds(&sim, LAGGING, namespace, commit_min + 1));
+ let requests = repair_requests(&sim);
+ assert_eq!(
+ requests.len(),
+ 1,
+ "adopted headers do not supply the missing body"
+ );
+ assert_eq!(requests[0].from_op, commit_min + 1);
+ assert_eq!(requests[0].to_op, OPS as u64);
+
+ let partition = shard.plane.partitions().get_mut_by_ns(&namespace).unwrap();
+ futures::executor::block_on(partition.apply_repaired_prepare(missing));
+ assert!(journal_holds(&sim, LAGGING, namespace, OPS as u64));
+ deliver_commit(shard, namespace, OPS as u64);
+ assert_eq!(
+ group_state(&sim, LAGGING, namespace),
+ (Status::Normal, 0, OPS as u64, OPS as u64)
+ );
+ }
+
+ fn resident_repair_window(
+ namespace: IggyNamespace,
+ total_ops: usize,
+ resident_ops: usize,
+ ) -> Simulator {
+ let (mut sim, client) = cluster(0x5EED_0240);
+ sim.init_partition(namespace);
+ sim.register_client_with_primary(&client);
+ sim.replica_crash(LAGGING);
+ for _ in 0..total_ops {
+ let reply = tests::submit_and_wait_for_reply(
+ &mut sim,
+ CLIENT_ID,
+ 0,
+ client.send_messages(namespace, &[Bytes::from_static(b"resident-repair")]),
+ );
+ assert_eq!(reply.header().status, 0);
+ }
+ assert_eq!(group_state(&sim, 0, namespace).2, total_ops as u64);
+
+ // Replay real prepares without running the crashed backup's pump so its
+ // resident backlog reaches the commit edge in one bounded walk.
+ let shard = sim.replicas[usize::from(LAGGING)].partition_shard(namespace);
+ for op in 1..=resident_ops as u64 {
+ let prepare = tests::retained_prepare(&sim, 0, namespace, op);
+ let partition = shard.plane.partitions().get_mut_by_ns(&namespace).unwrap();
+ futures::executor::block_on(partition.on_replicate(prepare));
+ assert!(journal_holds(&sim, LAGGING, namespace, op));
+ }
+ assert_eq!(group_state(&sim, LAGGING, namespace).2, 0);
+ sim.outboxes[usize::from(LAGGING)].drain();
+ sim
+ }
+
+ fn deliver_commit(shard: &Replica, namespace: IggyNamespace, commit: u64) {
+ let message = Message::<CommitHeader>::new(size_of::<CommitHeader>()).transmute_header(
+ |_, header: &mut CommitHeader| {
+ header.command = Command::Commit;
+ header.cluster = 1;
+ header.replica = 0;
+ header.group = namespace.inner();
+ header.commit = commit;
+ header.timestamp_monotonic = commit;
+ header.size = u32::try_from(size_of::<CommitHeader>()).unwrap();
+ header.seal();
+ },
+ );
+ futures::executor::block_on(shard.on_message(MessageBag::Commit(message)));
+ }
+
+ fn repair_requests(sim: &Simulator) -> Vec<RequestPreparesHeader> {
+ sim.outboxes[usize::from(LAGGING)]
+ .drain()
+ .into_iter()
+ .filter_map(|envelope| match envelope.payload {
+ bus::EnvelopePayload::Replica(message)
+ if message.header().command == Command::RequestPrepares =>
+ {
+ let header = *bytemuck::checked::from_bytes::<RequestPreparesHeader>(
+ &message.as_slice()[..size_of::<RequestPreparesHeader>()],
+ );
+ assert_eq!(envelope.to_replica, Some(0));
+ Some(header)
+ }
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
fn given_a_backup_that_dropped_a_committed_prepare_when_heartbeat_advances_are_starved_should_repair_in_normal_status()
{
// Statics, not captures: the link hooks are bare `fn` pointers. Declared
diff --git a/core/simulator/src/replica.rs b/core/simulator/src/replica.rs
index 09096c0..a828bbe 100644
--- a/core/simulator/src/replica.rs
+++ b/core/simulator/src/replica.rs
@@ -19,7 +19,7 @@
use crate::deps::SimSuperblock;
use crate::deps::{MemStorage, SimJournal, SimMuxStateMachine, SimSnapshot};
use configs::server::PersonalAccessTokenConfig;
-use configs::server::ServerSystemConfig;
+use configs::server::ServerConfig;
use consensus::{ClientTable, ConsensusClock, LocalPipeline, Sequencer, VsrConsensus, VsrState};
use iggy_common::IggyByteSize;
use iggy_common::variadic;
@@ -379,10 +379,9 @@
let partitions_config = PartitionsConfig {
messages_required_to_save: 1000,
size_of_messages_required_to_save: IggyByteSize::from(4 * 1024 * 1024),
- enforce_fsync: false, //Disable fsync for simulation
- consumer_offset_enforce_fsync: false,
+
validate_checksum: true,
- segment_size: IggyByteSize::from(1024 * 1024 * 1024),
+ segment_size: IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE),
preallocate_segments: false,
encryptor: None,
path_layout: PartitionPathLayout::default(),
@@ -415,7 +414,7 @@
wire_shell_handlers(
&SharedSimOutbox(Rc::clone(bus)),
&shard_handle,
- Arc::new(ServerSystemConfig::default()),
+ Arc::new(ServerConfig::default()),
// Default-config PAT cap, like the system config above, so sim
// ingress admits exactly what a default-configured server does.
PersonalAccessTokenConfig::default().max_tokens_per_user,
diff --git a/core/simulator/src/storage.rs b/core/simulator/src/storage.rs
new file mode 100644
index 0000000..d8100dc
--- /dev/null
+++ b/core/simulator/src/storage.rs
@@ -0,0 +1,585 @@
+// 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.
+
+//! Deterministic filesystem model for persistence ordering and power-loss tests.
+
+#![allow(clippy::future_not_send)]
+
+use journal::durable_storage::{DurableFile, DurableStorage, OpenMode, StorageEntry};
+use server_common::iobuf::Frozen;
+use std::cell::RefCell;
+use std::collections::BTreeMap;
+use std::ffi::OsString;
+use std::io;
+use std::path::{Component, Path};
+use std::rc::Rc;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Crash {
+ Process,
+ PowerLoss,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum FaultMode {
+ Before,
+ After,
+ TornWrite,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum StorageOperation {
+ Open,
+ Create,
+ Read,
+ Write,
+ Length,
+ Truncate,
+ FileSync,
+ CreateDirectory,
+ DirectorySync,
+ Rename,
+ Unlink,
+ Link,
+ Exists,
+ List,
+ RemoveTree,
+}
+
+#[derive(Clone, Default)]
+pub struct SimStorage {
+ state: Rc<RefCell<State>>,
+}
+
+pub struct SimFile {
+ storage: SimStorage,
+ inode: usize,
+ epoch: u64,
+}
+
+#[derive(Clone)]
+enum Inode {
+ File {
+ buffered: Vec<u8>,
+ stable: Vec<u8>,
+ },
+ Directory {
+ entries: BTreeMap<OsString, usize>,
+ stable: BTreeMap<OsString, usize>,
+ },
+}
+
+#[derive(Clone)]
+struct State {
+ inodes: Vec<Inode>,
+ epoch: u64,
+ trace: Vec<StorageOperation>,
+ written_bytes: BTreeMap<usize, usize>,
+ fault: Option<(usize, FaultMode)>,
+ paused: Option<StorageOperation>,
+ waiters: Vec<std::task::Waker>,
+}
+
+impl SimStorage {
+ #[must_use]
+ pub fn trace(&self) -> Vec<StorageOperation> {
+ self.state.borrow().trace.clone()
+ }
+
+ pub fn clear_trace(&self) {
+ let mut state = self.state.borrow_mut();
+ state.trace.clear();
+ state.fault = None;
+ }
+
+ pub fn fail_at(&self, operation: usize, mode: FaultMode) {
+ let mut state = self.state.borrow_mut();
+ state.trace.clear();
+ state.fault = Some((operation, mode));
+ }
+
+ /// A process restart preserves the OS cache. Power loss discards it.
+ /// Every restart invalidates open handles owned by the old process.
+ pub fn crash(&self, crash: Crash) {
+ let mut state = self.state.borrow_mut();
+ state.epoch += 1;
+ if crash == Crash::PowerLoss {
+ for inode in &mut state.inodes {
+ match inode {
+ Inode::File { buffered, stable } => buffered.clone_from(stable),
+ Inode::Directory { entries, stable } => entries.clone_from(stable),
+ }
+ }
+ }
+ state.trace.clear();
+ state.fault = None;
+ }
+
+ /// Model background writeback without attributing a durability barrier to it.
+ pub fn writeback(&self) {
+ for inode in &mut self.state.borrow_mut().inodes {
+ match inode {
+ Inode::File { buffered, stable } => stable.clone_from(buffered),
+ Inode::Directory { entries, stable } => stable.clone_from(entries),
+ }
+ }
+ }
+
+ pub fn pause_writes(&self) {
+ self.state.borrow_mut().paused = Some(StorageOperation::Write);
+ }
+
+ pub fn pause_file_syncs(&self) {
+ self.state.borrow_mut().paused = Some(StorageOperation::FileSync);
+ }
+
+ pub fn resume(&self) {
+ let waiters = {
+ let mut state = self.state.borrow_mut();
+ state.paused = None;
+ std::mem::take(&mut state.waiters)
+ };
+ for waiter in waiters {
+ waiter.wake();
+ }
+ }
+
+ async fn wait_for(&self, operation: StorageOperation) {
+ futures::future::poll_fn(|context| {
+ let mut state = self.state.borrow_mut();
+ if state.paused != Some(operation) {
+ return std::task::Poll::Ready(());
+ }
+ if !state
+ .waiters
+ .iter()
+ .any(|waiter| waiter.will_wake(context.waker()))
+ {
+ state.waiters.push(context.waker().clone());
+ }
+ std::task::Poll::Pending
+ })
+ .await;
+ }
+
+ fn perform<T>(
+ &self,
+ operation: StorageOperation,
+ action: impl FnOnce(&mut State, bool) -> io::Result<T>,
+ ) -> io::Result<T> {
+ let mut state = self.state.borrow_mut();
+ let index = state.trace.len();
+ state.trace.push(operation);
+ let mode = state
+ .fault
+ .filter(|(at, _)| *at == index)
+ .map(|(_, mode)| mode);
+ if mode.is_some() {
+ state.fault = None;
+ }
+ if mode == Some(FaultMode::Before) {
+ return Err(io::Error::other("injected storage failure"));
+ }
+ let result = action(&mut state, mode == Some(FaultMode::TornWrite))?;
+ if mode.is_some() {
+ return Err(io::Error::other("injected failure after storage effect"));
+ }
+ Ok(result)
+ }
+}
+
+impl DurableStorage for SimStorage {
+ type File = SimFile;
+
+ async fn open(&self, path: &Path, mode: OpenMode) -> io::Result<SimFile> {
+ let creates = matches!(mode, OpenMode::Create | OpenMode::CreateOrOpen);
+ let operation = if creates {
+ StorageOperation::Create
+ } else {
+ StorageOperation::Open
+ };
+ self.wait_for(operation).await;
+ let (inode, epoch) = self.perform(operation, |state, _| {
+ let inode = if creates {
+ let (parent, name) = state.parent(path)?;
+ if let Some(&inode) = state.directory(parent)?.get(&name) {
+ match &mut state.inodes[inode] {
+ Inode::File { buffered, .. } => {
+ if mode == OpenMode::Create {
+ buffered.clear();
+ }
+ }
+ Inode::Directory { .. } => {
+ return Err(invalid("cannot truncate directory"));
+ }
+ }
+ inode
+ } else {
+ let inode = state.inodes.len();
+ state.inodes.push(Inode::File {
+ buffered: Vec::new(),
+ stable: Vec::new(),
+ });
+ state.directory_mut(parent)?.insert(name, inode);
+ inode
+ }
+ } else {
+ state.lookup(path)?
+ };
+ Ok((inode, state.epoch))
+ })?;
+ Ok(SimFile {
+ storage: self.clone(),
+ inode,
+ epoch,
+ })
+ }
+
+ async fn create_directories(&self, path: &Path) -> io::Result<()> {
+ self.perform(StorageOperation::CreateDirectory, |state, _| {
+ let mut parent = 0;
+ for name in components(path)? {
+ parent = if let Some(&inode) = state.directory(parent)?.get(&name) {
+ inode
+ } else {
+ let inode = state.inodes.len();
+ state.inodes.push(Inode::directory());
+ state.directory_mut(parent)?.insert(name, inode);
+ inode
+ };
+ state.directory(parent)?;
+ }
+ Ok(())
+ })
+ }
+
+ async fn sync_directory(&self, path: &Path) -> io::Result<()> {
+ self.perform(StorageOperation::DirectorySync, |state, _| {
+ let inode = state.lookup(path)?;
+ match &mut state.inodes[inode] {
+ Inode::Directory { entries, stable } => stable.clone_from(entries),
+ Inode::File { .. } => return Err(invalid("directory sync on a file")),
+ }
+ Ok(())
+ })
+ }
+
+ async fn rename(&self, source: &Path, target: &Path) -> io::Result<()> {
+ self.perform(StorageOperation::Rename, |state, _| {
+ let (parent, name) = state.parent(source)?;
+ let (target_parent, target_name) = state.parent(target)?;
+ let inode = *state.directory(parent)?.get(&name).ok_or_else(missing)?;
+ if let Some(&target_inode) = state.directory(target_parent)?.get(&target_name) {
+ if inode == target_inode {
+ return Ok(());
+ }
+ match (&state.inodes[inode], &state.inodes[target_inode]) {
+ (Inode::Directory { .. }, Inode::Directory { entries, .. })
+ if !entries.is_empty() =>
+ {
+ return Err(io::Error::from(io::ErrorKind::DirectoryNotEmpty));
+ }
+ (Inode::File { .. }, Inode::Directory { .. }) => {
+ return Err(io::Error::from(io::ErrorKind::IsADirectory));
+ }
+ (Inode::Directory { .. }, Inode::File { .. }) => {
+ return Err(io::Error::from(io::ErrorKind::NotADirectory));
+ }
+ _ => {}
+ }
+ }
+ state.directory_mut(parent)?.remove(&name);
+ state
+ .directory_mut(target_parent)?
+ .insert(target_name, inode);
+ Ok(())
+ })
+ }
+
+ async fn remove_file(&self, path: &Path) -> io::Result<()> {
+ self.perform(StorageOperation::Unlink, |state, _| {
+ let (parent, name) = state.parent(path)?;
+ state
+ .directory_mut(parent)?
+ .remove(&name)
+ .ok_or_else(missing)?;
+ Ok(())
+ })
+ }
+
+ async fn hard_link(&self, source: &Path, target: &Path) -> io::Result<()> {
+ self.wait_for(StorageOperation::Link).await;
+ self.perform(StorageOperation::Link, |state, _| {
+ let inode = state.lookup(source)?;
+ let (parent, name) = state.parent(target)?;
+ if state.directory(parent)?.contains_key(&name) {
+ return Err(io::Error::from(io::ErrorKind::AlreadyExists));
+ }
+ state.directory_mut(parent)?.insert(name, inode);
+ Ok(())
+ })
+ }
+
+ async fn exists(&self, path: &Path) -> io::Result<bool> {
+ self.perform(StorageOperation::Exists, |state, _| {
+ match state.lookup(path) {
+ Ok(_) => Ok(true),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(error),
+ }
+ })
+ }
+
+ async fn entries(&self, path: &Path) -> io::Result<Vec<StorageEntry>> {
+ self.perform(StorageOperation::List, |state, _| {
+ let inode = state.lookup(path)?;
+ Ok(state
+ .directory(inode)?
+ .iter()
+ .map(|(name, &child)| StorageEntry {
+ name: name.clone(),
+ directory: matches!(state.inodes[child], Inode::Directory { .. }),
+ })
+ .collect())
+ })
+ }
+
+ async fn remove_tree(&self, path: &Path) -> io::Result<()> {
+ let mut pending = vec![(path.to_path_buf(), false)];
+ while let Some((path, visited)) = pending.pop() {
+ let children = self.perform(StorageOperation::RemoveTree, |state, _| {
+ let inode = match state.lookup(&path) {
+ Ok(inode) => inode,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
+ Err(error) => return Err(error),
+ };
+ if !visited
+ && let Inode::Directory { entries, .. } = &state.inodes[inode]
+ && !entries.is_empty()
+ {
+ return Ok(entries.keys().map(|name| path.join(name)).collect());
+ }
+ let (parent, name) = state.parent(&path)?;
+ state.directory_mut(parent)?.remove(&name);
+ Ok(Vec::new())
+ })?;
+ if !children.is_empty() {
+ pending.push((path, true));
+ pending.extend(children.into_iter().map(|path| (path, false)));
+ }
+ }
+ Ok(())
+ }
+}
+
+impl DurableFile for SimFile {
+ async fn read(&self, offset: u64, length: usize) -> io::Result<Vec<u8>> {
+ self.storage.perform(StorageOperation::Read, |state, _| {
+ let buffered = state.file(self.inode, self.epoch)?;
+ let offset = usize::try_from(offset).map_err(|_| invalid("offset overflow"))?;
+ let end = offset
+ .checked_add(length)
+ .ok_or_else(|| invalid("read overflow"))?;
+ buffered
+ .get(offset..end)
+ .map(<[u8]>::to_vec)
+ .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))
+ })
+ }
+
+ async fn write(&mut self, offset: u64, bytes: Vec<u8>) -> io::Result<()> {
+ self.write_chunks(offset, std::iter::once(bytes.as_slice()))
+ .await
+ }
+
+ async fn write_frozen(&mut self, offset: u64, bytes: Frozen<4096>) -> io::Result<()> {
+ self.write_chunks(offset, std::iter::once(bytes.as_slice()))
+ .await
+ }
+
+ async fn write_frozen_vectored(
+ &mut self,
+ offset: u64,
+ buffers: Vec<Frozen<4096>>,
+ ) -> io::Result<()> {
+ self.write_chunks(offset, buffers.iter().map(Frozen::as_slice))
+ .await
+ }
+
+ async fn length(&self) -> io::Result<u64> {
+ self.storage.perform(StorageOperation::Length, |state, _| {
+ Ok(state.file(self.inode, self.epoch)?.len() as u64)
+ })
+ }
+
+ async fn truncate(&self, length: u64) -> io::Result<()> {
+ self.storage
+ .perform(StorageOperation::Truncate, |state, _| {
+ let length = usize::try_from(length).map_err(|_| invalid("length overflow"))?;
+ state.file_mut(self.inode, self.epoch)?.resize(length, 0);
+ Ok(())
+ })
+ }
+
+ async fn sync(&self) -> io::Result<()> {
+ self.storage.wait_for(StorageOperation::FileSync).await;
+ self.storage
+ .perform(StorageOperation::FileSync, |state, _| {
+ state.file(self.inode, self.epoch)?;
+ if let Inode::File { buffered, stable } = &mut state.inodes[self.inode] {
+ stable.clone_from(buffered);
+ }
+ Ok(())
+ })
+ }
+}
+
+impl SimFile {
+ async fn write_chunks<'a>(
+ &self,
+ offset: u64,
+ chunks: impl Iterator<Item = &'a [u8]> + Clone,
+ ) -> io::Result<()> {
+ let length = chunks.clone().try_fold(0usize, |length, chunk| {
+ length
+ .checked_add(chunk.len())
+ .ok_or_else(|| invalid("write overflow"))
+ })?;
+ self.storage.wait_for(StorageOperation::Write).await;
+ self.storage
+ .perform(StorageOperation::Write, |state, torn| {
+ let buffered = state.file_mut(self.inode, self.epoch)?;
+ let offset = usize::try_from(offset).map_err(|_| invalid("offset overflow"))?;
+ let length = if torn { length / 2 } else { length };
+ let end = offset
+ .checked_add(length)
+ .ok_or_else(|| invalid("write overflow"))?;
+ if end > buffered.len() {
+ buffered.resize(end, 0);
+ }
+ let mut position = offset;
+ for chunk in chunks {
+ let written = chunk.len().min(end - position);
+ buffered[position..position + written].copy_from_slice(&chunk[..written]);
+ position += written;
+ if position == end {
+ break;
+ }
+ }
+ *state.written_bytes.entry(self.inode).or_default() += length;
+ Ok(())
+ })
+ }
+}
+
+impl Default for State {
+ fn default() -> Self {
+ Self {
+ inodes: vec![Inode::directory()],
+ epoch: 0,
+ trace: Vec::new(),
+ written_bytes: BTreeMap::new(),
+ fault: None,
+ paused: None,
+ waiters: Vec::new(),
+ }
+ }
+}
+
+impl State {
+ fn lookup(&self, path: &Path) -> io::Result<usize> {
+ let mut inode = 0;
+ for name in components(path)? {
+ inode = *self.directory(inode)?.get(&name).ok_or_else(missing)?;
+ }
+ Ok(inode)
+ }
+
+ fn parent(&self, path: &Path) -> io::Result<(usize, OsString)> {
+ let mut names = components(path)?;
+ let name = names.pop().ok_or_else(|| invalid("root has no parent"))?;
+ let mut inode = 0;
+ for name in names {
+ inode = *self.directory(inode)?.get(&name).ok_or_else(missing)?;
+ }
+ Ok((inode, name))
+ }
+
+ fn directory(&self, inode: usize) -> io::Result<&BTreeMap<OsString, usize>> {
+ match &self.inodes[inode] {
+ Inode::Directory { entries, .. } => Ok(entries),
+ Inode::File { .. } => Err(invalid("not a directory")),
+ }
+ }
+
+ fn directory_mut(&mut self, inode: usize) -> io::Result<&mut BTreeMap<OsString, usize>> {
+ match &mut self.inodes[inode] {
+ Inode::Directory { entries, .. } => Ok(entries),
+ Inode::File { .. } => Err(invalid("not a directory")),
+ }
+ }
+
+ fn file(&self, inode: usize, epoch: u64) -> io::Result<&Vec<u8>> {
+ if epoch != self.epoch {
+ return Err(invalid("stale process file handle"));
+ }
+ match &self.inodes[inode] {
+ Inode::File { buffered, .. } => Ok(buffered),
+ Inode::Directory { .. } => Err(invalid("not a file")),
+ }
+ }
+
+ fn file_mut(&mut self, inode: usize, epoch: u64) -> io::Result<&mut Vec<u8>> {
+ if epoch != self.epoch {
+ return Err(invalid("stale process file handle"));
+ }
+ match &mut self.inodes[inode] {
+ Inode::File { buffered, .. } => Ok(buffered),
+ Inode::Directory { .. } => Err(invalid("not a file")),
+ }
+ }
+}
+
+impl Inode {
+ const fn directory() -> Self {
+ Self::Directory {
+ entries: BTreeMap::new(),
+ stable: BTreeMap::new(),
+ }
+ }
+}
+
+fn components(path: &Path) -> io::Result<Vec<OsString>> {
+ path.components()
+ .filter_map(|component| match component {
+ Component::Normal(name) => Some(Ok(name.to_os_string())),
+ Component::RootDir | Component::CurDir => None,
+ _ => Some(Err(invalid("unsupported modeled path"))),
+ })
+ .collect()
+}
+
+fn invalid(message: &'static str) -> io::Error {
+ io::Error::new(io::ErrorKind::InvalidData, message)
+}
+
+fn missing() -> io::Error {
+ io::Error::from(io::ErrorKind::NotFound)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/core/simulator/src/storage/tests.rs b/core/simulator/src/storage/tests.rs
new file mode 100644
index 0000000..1901401
--- /dev/null
+++ b/core/simulator/src/storage/tests.rs
@@ -0,0 +1,2519 @@
+// 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 super::{
+ Crash, DurableFile, DurableStorage, FaultMode, OpenMode, SimStorage, StorageOperation,
+};
+use crate::packet::PacketSimulatorOptions;
+use consensus::MetadataHandle;
+use futures::{executor::block_on, poll};
+use iggy_binary_protocol::batch::BATCH_HEADER_SIZE;
+use iggy_binary_protocol::{Command, Operation, PrepareHeader};
+use journal::partition_journal::{PARTITION_WAL_BLOCK_SIZE, SegmentPosition, SegmentReference};
+use journal::{DurableAppend, PartitionPrepareJournal};
+use partitions::{PartitionPersistence, install_backup};
+use server_common::send_messages::{
+ BATCH_MESSAGE_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
+};
+use server_common::sharding::IggyNamespace;
+use server_common::{
+ Message,
+ iobuf::{IOV_MAX, Owned},
+};
+use std::collections::BTreeSet;
+use std::io;
+use std::path::Path;
+use std::rc::Rc;
+use twox_hash::XxHash3_64;
+
+const DIRECTORY: &str = "/partition";
+const WAL: &str = "/partition/wal";
+const OWNED_BATCH_BYTES: usize = 12 * 1024;
+const MATERIALIZED_FILES: &[&str] = &[
+ "/partition/0.log",
+ "/partition/0.index",
+ "/partition/offsets/consumers/1",
+ "/partition/offsets/groups/9",
+ "/partition/superblock.a",
+];
+
+#[derive(Clone, Copy, Debug)]
+enum Mutation {
+ Append,
+ CertifyView,
+ Checkpoint,
+ Truncate,
+ Reset,
+ Purge,
+}
+
+#[test]
+fn process_crash_preserves_completed_writes_but_power_loss_requires_file_and_directory_sync() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ storage
+ .create_directories(Path::new(DIRECTORY))
+ .await
+ .unwrap();
+ storage.sync_directory(Path::new("/")).await.unwrap();
+ let path = Path::new("/partition/value");
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"buffered".to_vec()).await.unwrap();
+ storage.crash(Crash::Process);
+ assert_eq!(
+ storage
+ .open(path, OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 8)
+ .await
+ .unwrap(),
+ b"buffered"
+ );
+ storage.crash(Crash::PowerLoss);
+ assert!(!storage.exists(path).await.unwrap());
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"synced".to_vec()).await.unwrap();
+ file.sync().await.unwrap();
+ storage.crash(Crash::PowerLoss);
+ assert!(
+ !storage.exists(path).await.unwrap(),
+ "file sync must not imply directory sync"
+ );
+ replace(&storage, path, b"durable").await.unwrap();
+ storage.crash(Crash::PowerLoss);
+ assert_eq!(
+ storage
+ .open(path, OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 7)
+ .await
+ .unwrap(),
+ b"durable"
+ );
+ assert!(
+ file.sync().await.is_err(),
+ "an old process cannot complete into the new one"
+ );
+ });
+}
+
+#[test]
+fn wal_fault_sweep_preserves_acknowledged_history_at_every_io_boundary() {
+ block_on(async {
+ let mut cases = 0;
+ for mutation in [
+ Mutation::Append,
+ Mutation::CertifyView,
+ Mutation::Checkpoint,
+ Mutation::Truncate,
+ Mutation::Reset,
+ Mutation::Purge,
+ ] {
+ let (storage, mut journal) = baseline().await;
+ storage.clear_trace();
+ mutate(&storage, &mut journal, mutation).await.unwrap();
+ let trace = storage.trace();
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ for writeback in [false, true] {
+ let (storage, mut journal) = baseline().await;
+ storage.fail_at(cut, mode);
+ let completed = mutate(&storage, &mut journal, mutation).await.is_ok();
+ drop(journal);
+ if writeback {
+ storage.writeback();
+ }
+ storage.crash(crash);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone()).await.unwrap_or_else(|error| {
+ panic!("{mutation:?} cut {cut} {operation:?} {mode:?} {crash:?} writeback={writeback}: {error}");
+ });
+ assert_recovery(&storage, &recovered, mutation, completed).await;
+ cases += 1;
+ }
+ }
+ }
+ }
+ }
+ eprintln!("partition WAL fault cases: {cases}");
+ });
+}
+
+#[test]
+fn referenced_wal_fault_sweep_preserves_bodies_through_publication_and_reclamation() {
+ block_on(async {
+ let mut cases = 0;
+ for mutation in [
+ Mutation::Append,
+ Mutation::CertifyView,
+ Mutation::Checkpoint,
+ Mutation::Truncate,
+ Mutation::Reset,
+ Mutation::Purge,
+ ] {
+ let (storage, mut journal) = referenced_baseline().await;
+ storage.clear_trace();
+ mutate_referenced(&storage, &mut journal, mutation)
+ .await
+ .unwrap();
+ let trace = storage.trace();
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ for writeback in [false, true] {
+ let (storage, mut journal) = referenced_baseline().await;
+ storage.fail_at(cut, mode);
+ let completed = mutate_referenced(&storage, &mut journal, mutation)
+ .await
+ .is_ok();
+ drop(journal);
+ if writeback {
+ storage.writeback();
+ }
+ storage.crash(crash);
+ let context = format!(
+ "{mutation:?} cut {cut} {operation:?} {mode:?} {crash:?} writeback={writeback}"
+ );
+ let recovered = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap_or_else(|error| panic!("{context}: {error}"));
+ assert_referenced_recovery(&recovered, mutation, completed, &context)
+ .await;
+ cases += 1;
+ }
+ }
+ }
+ }
+ }
+ eprintln!("referenced partition WAL fault cases: {cases}");
+ });
+}
+
+#[test]
+fn transfer_fault_sweep_restores_one_complete_materialization_including_the_wal() {
+ block_on(async {
+ let (storage, mut journal) = baseline().await;
+ storage.clear_trace();
+ install(&storage, &mut journal).await.unwrap();
+ let trace = storage.trace();
+ let mut cases = 0;
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ let (storage, mut journal) = baseline().await;
+ storage.fail_at(cut, mode);
+ let completed = install(&storage, &mut journal).await.is_ok();
+ drop(journal);
+ storage.crash(crash);
+ install_backup::recover_with_storage(Path::new(DIRECTORY), &storage)
+ .await
+ .unwrap_or_else(|error| {
+ panic!("install cut {cut} {operation:?} {mode:?} {crash:?}: {error}")
+ });
+ let recovered = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ let value = storage
+ .open(Path::new("/partition/state"), OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 3)
+ .await
+ .unwrap();
+ match recovered.checkpoint_op() {
+ 0 => {
+ assert!(!completed);
+ assert_eq!(value, b"old");
+ assert_eq!(recovered.head(), 3);
+ }
+ 7 => {
+ assert_eq!(value, b"new");
+ assert_eq!(recovered.head(), 7);
+ }
+ other => panic!("mixed installed state at {other}"),
+ }
+ let expected: &[u8] = if recovered.checkpoint_op() == 0 {
+ b"old"
+ } else {
+ b"new"
+ };
+ for path in MATERIALIZED_FILES {
+ assert_eq!(
+ storage
+ .open(Path::new(path), OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 3)
+ .await
+ .unwrap(),
+ expected
+ );
+ }
+ cases += 1;
+ }
+ }
+ }
+ eprintln!("partition transfer fault cases: {cases}");
+ });
+}
+
+#[test]
+fn durable_quorum_covers_buffered_predecessors_and_losing_unsynced_replicas() {
+ block_on(async {
+ for replicas in [1, 2, 3, 5, 7] {
+ let sim = crate::Simulator::new(
+ replicas,
+ std::iter::empty(),
+ PacketSimulatorOptions::default(),
+ );
+ let quorum = sim.replicas[0].shards[0]
+ .plane
+ .metadata()
+ .consensus
+ .as_ref()
+ .unwrap()
+ .quorum_replication();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ let mut disks = Vec::new();
+ for replica in 0..replicas {
+ let storage = storage_for_partition().await;
+ let mut journal = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ journal
+ .append_buffered(first.clone().into_frozen())
+ .await
+ .unwrap();
+ if replica < quorum {
+ journal.append(second.clone().into_frozen()).await.unwrap();
+ } else {
+ journal
+ .append_buffered(second.clone().into_frozen())
+ .await
+ .unwrap();
+ }
+ disks.push(storage);
+ }
+ let mut survivors = 0;
+ for storage in disks {
+ storage.crash(Crash::PowerLoss);
+ let journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ if journal.head() == 2 {
+ assert!(journal.contains(first.header()));
+ assert!(journal.contains(second.header()));
+ survivors += 1;
+ } else {
+ assert_eq!(journal.head(), 0);
+ }
+ }
+ assert_eq!(survivors, quorum);
+ }
+ });
+}
+
+#[test]
+fn stalled_writer_does_not_release_acks_or_block_another_partition() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ persistence
+ .append(first.clone().into_frozen(), true)
+ .unwrap();
+ storage.pause_writes();
+ assert!(persistence.start());
+ let mut writer = Box::pin(Rc::clone(&persistence).run());
+ assert!(poll!(&mut writer).is_pending());
+ assert!(!persistence.is_durable(first.header()));
+ let independent = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, independent)
+ .await
+ .unwrap();
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ assert!(journal.contains(first.header()));
+ persistence.truncate_from(1);
+ let replacement = prepare_with_payload(1, 0, b"replacement");
+ persistence
+ .append(replacement.clone().into_frozen(), true)
+ .unwrap();
+ storage.resume();
+ writer.await;
+ assert!(!persistence.is_durable(first.header()));
+ assert!(persistence.is_durable(replacement.header()));
+ storage.crash(Crash::PowerLoss);
+ let journal = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert!(journal.contains(replacement.header()));
+ });
+}
+
+#[test]
+fn queue_capacity_and_retirement_withhold_unpersisted_acknowledgments() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let mut parent = 0;
+ let mut accepted = 0;
+ loop {
+ let prepare = prepare_with_payload(accepted + 1, parent, b"queued");
+ parent = prepare.header().checksum;
+ match persistence.append(prepare.into_frozen(), true) {
+ Ok(()) => accepted += 1,
+ Err(error) => {
+ assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
+ break;
+ }
+ }
+ }
+ assert!(accepted > 0);
+ assert!(!persistence.is_durable_through(accepted));
+ persistence.retire();
+ assert!(!persistence.start());
+ storage.crash(Crash::PowerLoss);
+ let journal = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(journal.head(), 0);
+ });
+}
+
+#[test]
+fn interrupted_rollback_can_itself_restart_at_every_io_boundary() {
+ block_on(async {
+ let storage = interrupted_install().await;
+ storage.clear_trace();
+ install_backup::recover_with_storage(Path::new(DIRECTORY), &storage)
+ .await
+ .unwrap();
+ let trace = storage.trace();
+ let mut cases = 0;
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ let storage = interrupted_install().await;
+ storage.fail_at(cut, mode);
+ let _ =
+ install_backup::recover_with_storage(Path::new(DIRECTORY), &storage).await;
+ storage.crash(crash);
+ install_backup::recover_with_storage(Path::new(DIRECTORY), &storage)
+ .await
+ .unwrap_or_else(|error| {
+ panic!("rollback cut {cut} {operation:?} {mode:?} {crash:?}: {error}")
+ });
+ let journal = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(journal.head(), 3);
+ assert_eq!(journal.checkpoint_op(), 0);
+ assert_eq!(
+ storage
+ .open(Path::new("/partition/state"), OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 3)
+ .await
+ .unwrap(),
+ b"old"
+ );
+ cases += 1;
+ }
+ }
+ }
+ eprintln!("partition rollback fault cases: {cases}");
+ });
+}
+
+#[test]
+fn failed_durable_completion_never_releases_a_prepare_ack() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ persistence
+ .append(first.clone().into_frozen(), true)
+ .unwrap();
+ storage.clear_trace();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let trace = storage.trace();
+ for cut in 0..trace.len() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ persistence
+ .append(first.clone().into_frozen(), true)
+ .unwrap();
+ storage.fail_at(cut, mode);
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_some());
+ assert!(!persistence.is_durable(first.header()));
+ assert!(!persistence.is_durable_through(1));
+ }
+ }
+ });
+}
+
+#[test]
+fn synchronized_corruption_and_shortening_of_owned_segment_blocks_is_refused() {
+ block_on(async {
+ let retained = Path::new("/partition/wal/segment-0-0.log");
+ for damage in ["bit flip", "zero block", "short file"] {
+ for block in 0..2 * OWNED_BATCH_BYTES / PARTITION_WAL_BLOCK_SIZE {
+ let (storage, journal) = owned_segment_baseline(false).await;
+ assert!(
+ journal
+ .prepares()
+ .await
+ .unwrap()
+ .iter()
+ .all(|prepare| prepare.header().checksum_body == 0)
+ );
+ drop(journal);
+ let mut file = storage.open(retained, OpenMode::ReadWrite).await.unwrap();
+ let offset = (block * PARTITION_WAL_BLOCK_SIZE) as u64;
+ match damage {
+ "bit flip" => {
+ let mut byte = file.read(offset, 1).await.unwrap();
+ byte[0] ^= 1;
+ file.write(offset, byte).await.unwrap();
+ }
+ "zero block" => file
+ .write(offset, vec![0; PARTITION_WAL_BLOCK_SIZE])
+ .await
+ .unwrap(),
+ "short file" => file.truncate(offset).await.unwrap(),
+ _ => unreachable!(),
+ }
+ file.sync().await.unwrap();
+ let damaged = file
+ .read(0, usize::try_from(file.length().await.unwrap()).unwrap())
+ .await
+ .unwrap();
+ storage.crash(Crash::PowerLoss);
+ assert!(
+ PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone()
+ )
+ .await
+ .is_err(),
+ "{damage}, block {block}"
+ );
+ let file = storage.open(retained, OpenMode::Read).await.unwrap();
+ assert_eq!(file.read(0, damaged.len()).await.unwrap(), damaged);
+ }
+ }
+ });
+}
+
+#[test]
+fn metadata_only_append_skips_segment_barriers_after_durable_bodies() {
+ block_on(async {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ let parent = journal
+ .prepares()
+ .await
+ .unwrap()
+ .last()
+ .unwrap()
+ .header()
+ .checksum;
+ let offset = prepare(3, parent).transmute_header(|original, header: &mut PrepareHeader| {
+ *header = original;
+ header.operation = Operation::StoreConsumerOffset;
+ header.checksum = header.identity_checksum();
+ });
+ storage.clear_trace();
+ journal.append(offset.clone().into_frozen()).await.unwrap();
+ assert_eq!(
+ storage
+ .trace()
+ .iter()
+ .filter(|operation| **operation == StorageOperation::FileSync)
+ .count(),
+ 2,
+ "only the WAL and frontier need new file barriers"
+ );
+ assert_eq!(
+ storage
+ .trace()
+ .iter()
+ .filter(|operation| **operation == StorageOperation::Exists)
+ .count(),
+ 0
+ );
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(
+ recovered
+ .prepares()
+ .await
+ .unwrap()
+ .last()
+ .unwrap()
+ .as_slice(),
+ offset.as_slice()
+ );
+ });
+}
+
+#[test]
+fn replacing_a_retained_offset_writer_keeps_both_inodes_until_checkpoint() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(1).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let path = Path::new("/partition/offset");
+ let original = Path::new("/partition/original-offset");
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"original".to_vec()).await.unwrap();
+ storage.hard_link(path, original).await.unwrap();
+ persistence
+ .retain_offset_file(path.to_str().unwrap().to_owned(), file)
+ .await
+ .unwrap();
+ storage.remove_file(path).await.unwrap();
+ let mut replacement = storage.open(path, OpenMode::Create).await.unwrap();
+ replacement.write(0, b"replaced".to_vec()).await.unwrap();
+ persistence
+ .retain_offset_file(path.to_str().unwrap().to_owned(), replacement)
+ .await
+ .unwrap();
+
+ persistence.checkpoint_files(
+ 1,
+ vec![path.to_path_buf()],
+ vec![Path::new(DIRECTORY).to_path_buf()],
+ );
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ assert_eq!(persistence.checkpoint_op(), 1);
+ storage.crash(Crash::PowerLoss);
+ let file = storage.open(original, OpenMode::Read).await.unwrap();
+ assert_eq!(file.read(0, 8).await.unwrap(), b"original");
+ let file = storage.open(path, OpenMode::Read).await.unwrap();
+ assert_eq!(file.read(0, 8).await.unwrap(), b"replaced");
+ });
+}
+
+#[test]
+fn a_full_offset_writer_cache_synchronizes_overflow_and_reports_barrier_failure() {
+ const OFFSET_KEYS: usize = 128;
+ block_on(async {
+ let (storage, persistence) = queued_batch(1).await;
+ for key in 0..OFFSET_KEYS {
+ let path = format!("/partition/offset-{key}");
+ let mut file = storage
+ .open(Path::new(&path), OpenMode::Create)
+ .await
+ .unwrap();
+ file.write(0, b"cached".to_vec()).await.unwrap();
+ persistence.retain_offset_file(path, file).await.unwrap();
+ }
+ let path = Path::new("/partition/overflow");
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+ file.write(0, b"durable".to_vec()).await.unwrap();
+ storage.clear_trace();
+ persistence
+ .retain_offset_file(path.to_string_lossy().into_owned(), file)
+ .await
+ .unwrap();
+ assert_eq!(storage.trace(), vec![StorageOperation::FileSync]);
+ assert!(
+ persistence
+ .take_offset_file(path.to_str().unwrap())
+ .is_none()
+ );
+
+ let mut file = storage.open(path, OpenMode::ReadWrite).await.unwrap();
+ file.write(0, b"pending".to_vec()).await.unwrap();
+ storage.fail_at(0, FaultMode::Before);
+ assert!(
+ persistence
+ .retain_offset_file(path.to_string_lossy().into_owned(), file)
+ .await
+ .is_err()
+ );
+ storage.crash(Crash::PowerLoss);
+ let file = storage.open(path, OpenMode::Read).await.unwrap();
+ assert_eq!(file.read(0, 7).await.unwrap(), b"durable");
+ });
+}
+
+#[test]
+fn checkpoint_skips_duplicate_offset_sync_but_still_refuses_a_missing_path() {
+ block_on(async {
+ for missing in [false, true] {
+ let (storage, persistence) = queued_batch(1).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let path = Path::new("/partition/offset");
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"offset".to_vec()).await.unwrap();
+ persistence
+ .retain_offset_file(path.to_string_lossy().into_owned(), file)
+ .await
+ .unwrap();
+ if missing {
+ storage.remove_file(path).await.unwrap();
+ }
+ persistence.checkpoint_files(
+ 1,
+ vec![path.to_path_buf()],
+ vec![Path::new(DIRECTORY).to_path_buf()],
+ );
+ storage.clear_trace();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ if missing {
+ assert!(persistence.failure().is_some());
+ assert_eq!(persistence.checkpoint_op(), 0);
+ } else {
+ assert!(persistence.failure().is_none());
+ assert_eq!(persistence.checkpoint_op(), 1);
+ assert_eq!(
+ storage
+ .trace()
+ .iter()
+ .filter(|operation| **operation == StorageOperation::FileSync)
+ .count(),
+ 3,
+ "original offset writer, replacement WAL, frontier"
+ );
+ }
+ }
+ });
+}
+
+#[test]
+fn synchronized_corruption_in_any_record_block_is_refused() {
+ block_on(async {
+ for block in 0..8 {
+ let (storage, journal) = baseline().await;
+ drop(journal);
+ let mut file = storage
+ .open(
+ Path::new("/partition/wal/prepares-0.wal"),
+ OpenMode::ReadWrite,
+ )
+ .await
+ .unwrap();
+ let offset = block * 4096 + 40;
+ let mut byte = file.read(offset, 1).await.unwrap();
+ byte[0] ^= 1;
+ file.write(offset, byte).await.unwrap();
+ file.sync().await.unwrap();
+ storage.crash(Crash::PowerLoss);
+ assert!(
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .is_err()
+ );
+ }
+ });
+}
+
+async fn interrupted_install() -> SimStorage {
+ let (storage, mut journal) = baseline().await;
+ install_backup::begin_with_storage(Path::new(DIRECTORY), &storage)
+ .await
+ .unwrap();
+ replace(&storage, Path::new("/partition/state"), b"new")
+ .await
+ .unwrap();
+ journal.reset(7, None).await.unwrap();
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ storage
+}
+
+#[test]
+fn lost_frontier_cannot_turn_a_durable_journal_into_an_empty_one() {
+ block_on(async {
+ let (storage, journal) = baseline().await;
+ drop(journal);
+ storage
+ .remove_file(Path::new("/partition/wal/frontier"))
+ .await
+ .unwrap();
+ storage.sync_directory(Path::new(WAL)).await.unwrap();
+ storage.crash(Crash::PowerLoss);
+ assert!(
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .is_err()
+ );
+ });
+}
+
+#[test]
+fn first_open_recovers_after_each_initialization_fault() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let trace = storage.trace();
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ let storage = storage_for_partition().await;
+ storage.fail_at(cut, mode);
+ let _ = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await;
+ storage.crash(crash);
+ let journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap_or_else(|error| {
+ panic!(
+ "first open cut {cut} {operation:?} {mode:?} {crash:?}: {error}"
+ )
+ });
+ assert_eq!(journal.head(), 0);
+ }
+ }
+ }
+ eprintln!(
+ "partition WAL initialization fault cases: {}",
+ trace.len() * 6
+ );
+ });
+}
+
+#[test]
+fn deleting_and_recreating_a_partition_fences_an_old_writer_completion() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let (old, _) = PartitionPersistence::open_with_storage(
+ Path::new("/partition/prepares-7"),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ let original = prepare(1, 0);
+ old.append(original.clone().into_frozen(), true).unwrap();
+ storage.pause_writes();
+ assert!(old.start());
+ let mut writer = Box::pin(Rc::clone(&old).run());
+ assert!(poll!(&mut writer).is_pending());
+ old.retire();
+ storage.remove_tree(Path::new(DIRECTORY)).await.unwrap();
+ storage.sync_directory(Path::new("/")).await.unwrap();
+ storage.resume();
+ storage
+ .create_directories(Path::new(DIRECTORY))
+ .await
+ .unwrap();
+ storage.sync_directory(Path::new("/")).await.unwrap();
+ let (new, _) = PartitionPersistence::open_with_storage(
+ Path::new("/partition/prepares-8"),
+ 42,
+ 8,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ writer.await;
+ assert!(!old.is_durable(original.header()));
+ let replacement = prepare_with_payload(1, 0, b"new incarnation");
+ new.append(replacement.clone().into_frozen(), true).unwrap();
+ assert!(new.start());
+ Rc::clone(&new).run().await;
+ assert!(new.is_durable(replacement.header()));
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(
+ Path::new("/partition/prepares-8"),
+ 42,
+ 8,
+ storage,
+ )
+ .await
+ .unwrap();
+ assert!(recovered.contains(replacement.header()));
+ assert!(!recovered.contains(original.header()));
+ });
+}
+
+#[test]
+fn independent_message_and_offset_barriers_cover_the_required_prefix() {
+ block_on(async {
+ for message_policy in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ for offset_policy in [
+ iggy_common::Durability::Replicated,
+ iggy_common::Durability::Persisted,
+ ] {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let store = prepare(2, first.header().checksum).transmute_header(
+ |old, header: &mut PrepareHeader| {
+ *header = old;
+ header.operation = Operation::StoreConsumerOffset;
+ header.checksum = header.identity_checksum();
+ },
+ );
+ let delete = prepare(3, store.header().checksum).transmute_header(
+ |old, header: &mut PrepareHeader| {
+ *header = old;
+ header.operation = Operation::DeleteConsumerOffset;
+ header.checksum = header.identity_checksum();
+ },
+ );
+ persistence
+ .append(first.into_frozen(), message_policy.is_persisted())
+ .unwrap();
+ persistence
+ .append(store.into_frozen(), offset_policy.is_persisted())
+ .unwrap();
+ persistence
+ .append(delete.into_frozen(), offset_policy.is_persisted())
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ // A shared barrier may persist weaker successors in the same batch.
+ let expected = if offset_policy.is_persisted() || message_policy.is_persisted() {
+ 3
+ } else {
+ 0
+ };
+ assert_eq!(recovered.head(), expected);
+ assert_eq!(recovered.prepares().await.unwrap().len() as u64, expected);
+ }
+ }
+ });
+}
+
+#[test]
+fn queued_prepares_share_a_barrier_and_survive_power_loss_together() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(65).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ let trace = storage.trace();
+ assert_eq!(
+ trace
+ .iter()
+ .filter(|operation| **operation == StorageOperation::FileSync)
+ .count(),
+ 4
+ );
+ assert_eq!(
+ trace
+ .iter()
+ .filter(|operation| **operation == StorageOperation::DirectorySync)
+ .count(),
+ 2
+ );
+ assert_eq!(
+ trace
+ .iter()
+ .filter(|operation| **operation == StorageOperation::Write)
+ .count(),
+ 4
+ );
+ assert!(persistence.is_durable_through(65));
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(recovered.prepares().await.unwrap().len(), 65);
+ });
+}
+
+#[test]
+fn failed_group_barrier_never_acknowledges_a_partial_batch() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(4).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let trace = storage.trace();
+ for cut in 0..trace.len() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ let (storage, persistence) = queued_batch(4).await;
+ storage.fail_at(cut, mode);
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let acknowledged = persistence.is_durable_through(4);
+ if persistence.failure().is_some() {
+ assert!(!acknowledged);
+ }
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert!(matches!(recovered.head(), 0 | 4));
+ if acknowledged {
+ assert_eq!(recovered.head(), 4);
+ }
+ }
+ }
+ });
+}
+
+#[test]
+fn checkpoint_syncs_the_retained_writer_before_reclaiming_its_history() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(4).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let path = Path::new("/partition/offset");
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"offset".to_vec()).await.unwrap();
+ assert!(
+ persistence
+ .retain_offset_file(path.to_string_lossy().into_owned(), file)
+ .await
+ .is_ok()
+ );
+ storage.remove_file(path).await.unwrap();
+ persistence.retire_offset_file(path.to_str().unwrap());
+ persistence.checkpoint_files(4, Vec::new(), vec![Path::new(DIRECTORY).to_path_buf()]);
+ storage.fail_at(0, FaultMode::Before);
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_some());
+ assert_eq!(persistence.checkpoint_op(), 0);
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(recovered.checkpoint_op(), 0);
+ assert_eq!(recovered.head(), 4);
+ });
+}
+
+#[test]
+fn checkpoint_barriers_complete_before_wal_reclamation() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(4).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let path = Path::new("/partition/materialized");
+ storage
+ .create_directories(Path::new(DIRECTORY))
+ .await
+ .unwrap();
+ let mut file = storage.open(path, OpenMode::Create).await.unwrap();
+ file.write(0, b"committed".to_vec()).await.unwrap();
+ persistence.checkpoint_files(
+ 4,
+ vec![path.to_path_buf()],
+ vec![Path::new(DIRECTORY).to_path_buf()],
+ );
+ assert!(persistence.checkpoint_pending());
+ assert!(!persistence.needs_checkpoint());
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(!persistence.checkpoint_pending());
+ assert_eq!(persistence.checkpoint_op(), 4);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ assert_eq!(recovered.checkpoint_op(), 4);
+ assert_eq!(
+ storage
+ .open(path, OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 9)
+ .await
+ .unwrap(),
+ b"committed"
+ );
+ });
+}
+
+#[test]
+fn failed_materialization_keeps_wal_coverage_and_fences_completion() {
+ block_on(async {
+ for missing_directory in [false, true] {
+ let (storage, persistence) = queued_batch(4).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let missing = vec![Path::new("/partition/missing").to_path_buf()];
+ let (files, directories) = if missing_directory {
+ (Vec::new(), missing)
+ } else {
+ (missing, Vec::new())
+ };
+ persistence.checkpoint_files(4, files, directories);
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert_eq!(
+ persistence.failure().unwrap().kind(),
+ io::ErrorKind::NotFound
+ );
+ assert_eq!(persistence.checkpoint_op(), 0);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(recovered.head(), 4);
+ assert_eq!(recovered.prepares().await.unwrap().len(), 4);
+ }
+ });
+}
+
+#[test]
+fn obsolete_wal_generations_are_reclaimed_after_restart_and_failed_unlink() {
+ block_on(async {
+ let (storage, mut journal) = baseline().await;
+ storage.clear_trace();
+ journal.checkpoint(2).await.unwrap();
+ let unlink = storage
+ .trace()
+ .iter()
+ .position(|operation| *operation == StorageOperation::Unlink)
+ .unwrap();
+ for restart in [false, true] {
+ let (storage, mut journal) = baseline().await;
+ storage.fail_at(unlink, FaultMode::Before);
+ journal.checkpoint(2).await.unwrap();
+ storage.clear_trace();
+ let obsolete = Path::new("/partition/wal/prepares-0.wal");
+ assert!(storage.exists(obsolete).await.unwrap());
+ if restart {
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ journal = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap();
+ } else {
+ let parent = journal
+ .prepares()
+ .await
+ .unwrap()
+ .last()
+ .map(|prepare| {
+ bytemuck::checked::from_bytes::<PrepareHeader>(
+ &prepare.as_slice()[..size_of::<PrepareHeader>()],
+ )
+ .checksum
+ })
+ .unwrap();
+ journal
+ .append(prepare(4, parent).into_frozen())
+ .await
+ .unwrap();
+ }
+ assert!(!storage.exists(obsolete).await.unwrap());
+ assert_eq!(journal.checkpoint_op(), 2);
+ assert!(journal.prepares().await.unwrap().iter().any(|prepare| {
+ bytemuck::checked::from_bytes::<PrepareHeader>(
+ &prepare.as_slice()[..size_of::<PrepareHeader>()],
+ )
+ .op == 2
+ }));
+ }
+ });
+}
+
+#[test]
+fn dropping_a_stalled_writer_restores_ownership_and_releases_drain_waiters() {
+ block_on(async {
+ let (storage, persistence) = queued_batch(1).await;
+ storage.pause_writes();
+ assert!(persistence.start());
+ let mut writer = Box::pin(Rc::clone(&persistence).run());
+ assert!(poll!(&mut writer).is_pending());
+ let mut drain = Box::pin(persistence.drain());
+ assert!(poll!(&mut drain).is_pending());
+ drop(writer);
+ assert_eq!(drain.await.unwrap_err().kind(), io::ErrorKind::Interrupted);
+ assert!(!persistence.start());
+ });
+}
+
+#[test]
+fn rename_rejects_a_nonempty_directory_and_a_fault_is_transient() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ storage
+ .create_directories(Path::new("/source"))
+ .await
+ .unwrap();
+ storage
+ .create_directories(Path::new("/target/child"))
+ .await
+ .unwrap();
+ assert_eq!(
+ storage
+ .rename(Path::new("/source"), Path::new("/target"))
+ .await
+ .unwrap_err()
+ .kind(),
+ io::ErrorKind::DirectoryNotEmpty
+ );
+ assert!(storage.exists(Path::new("/source")).await.unwrap());
+ storage.fail_at(0, FaultMode::Before);
+ assert!(storage.remove_tree(Path::new("/target")).await.is_err());
+ storage.remove_tree(Path::new("/target")).await.unwrap();
+ assert!(!storage.exists(Path::new("/target")).await.unwrap());
+ });
+}
+
+async fn storage_for_partition() -> SimStorage {
+ let storage = SimStorage::default();
+ storage
+ .create_directories(Path::new(DIRECTORY))
+ .await
+ .unwrap();
+ storage.sync_directory(Path::new("/")).await.unwrap();
+ storage.clear_trace();
+ storage
+}
+
+async fn queued_batch(count: u64) -> (SimStorage, Rc<PartitionPersistence<SimStorage>>) {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let mut parent = 0;
+ for op in 1..=count {
+ let prepare = prepare(op, parent);
+ parent = prepare.header().checksum;
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ }
+ storage.clear_trace();
+ (storage, persistence)
+}
+
+async fn baseline() -> (SimStorage, PartitionPrepareJournal<SimStorage>) {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let mut parent = 0;
+ for op in 1..=3 {
+ let prepare = prepare(op, parent);
+ parent = prepare.header().checksum;
+ journal.append(prepare.into_frozen()).await.unwrap();
+ }
+ replace(&storage, Path::new("/partition/state"), b"old")
+ .await
+ .unwrap();
+ for path in MATERIALIZED_FILES {
+ let path = Path::new(path);
+ storage
+ .create_directories(path.parent().unwrap())
+ .await
+ .unwrap();
+ replace(&storage, path, b"old").await.unwrap();
+ }
+ storage
+ .sync_directory(Path::new("/partition/offsets"))
+ .await
+ .unwrap();
+ storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+ (storage, journal)
+}
+
+#[test]
+fn segment_roll_during_wal_create_keeps_the_same_inode() {
+ block_on(segment_roll_during_wal_open(StorageOperation::Create));
+}
+
+#[test]
+fn segment_roll_during_wal_link_keeps_the_same_inode() {
+ block_on(segment_roll_during_wal_open(StorageOperation::Link));
+}
+
+async fn segment_roll_during_wal_open(operation: StorageOperation) {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), OWNED_BATCH_BYTES as u64)
+ .await
+ .unwrap();
+ let first = owned_prepare(1, 0, 0);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ let second = owned_prepare(2, first.header().checksum, 1);
+ let public = Path::new(DIRECTORY).join(format!("{:020}.log", 1));
+ let retained = Path::new(WAL).join("segment-1-1.log");
+ storage.state.borrow_mut().paused = Some(operation);
+ let mut append = Box::pin(journal.append(second.clone().into_frozen()));
+ assert!(poll!(&mut append).is_pending());
+ storage.resume();
+ let roll_reader = storage.open(&public, OpenMode::CreateOrOpen).await.unwrap();
+ append
+ .await
+ .expect("a concurrent segment roll must not fail the WAL append");
+ {
+ let state = storage.state.borrow();
+ assert_eq!(
+ state.lookup(&public).unwrap(),
+ state.lookup(&retained).unwrap()
+ );
+ assert_eq!(
+ roll_reader.inode,
+ state.lookup(&public).unwrap(),
+ "the WAL must retain the inode opened by the segment roll"
+ );
+ }
+ assert_eq!(
+ roll_reader.read(0, OWNED_BATCH_BYTES).await.unwrap(),
+ second.as_slice()[size_of::<PrepareHeader>()..]
+ );
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ assert_eq!(recovered.durable_op(), 2);
+ let actual = recovered.prepares().await.unwrap();
+ for (actual, expected) in actual.iter().zip([first, second]) {
+ assert_eq!(actual.as_slice(), expected.as_slice());
+ }
+ assert_eq!(actual.len(), 2);
+ let state = storage.state.borrow();
+ assert_eq!(
+ state.lookup(&public).unwrap(),
+ state.lookup(&retained).unwrap()
+ );
+}
+
+#[test]
+fn buffered_owned_segments_rotate_without_barriers_and_persist_offset_predecessors() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), OWNED_BATCH_BYTES as u64)
+ .await
+ .unwrap();
+ storage.clear_trace();
+ let written_before: usize = storage.state.borrow().written_bytes.values().sum();
+ let mut parent = 0;
+ let mut prepares = Vec::new();
+ for offset in 0..3 {
+ let prepare = owned_prepare(offset + 1, parent, offset);
+ parent = prepare.header().checksum;
+ journal
+ .append_buffered(prepare.clone().into_frozen())
+ .await
+ .unwrap();
+ prepares.push(prepare);
+ }
+ assert!(
+ !storage.trace().iter().any(|operation| matches!(
+ operation,
+ StorageOperation::FileSync | StorageOperation::DirectorySync
+ )),
+ "replicated bodies must not require a barrier, including across append groups and rotations"
+ );
+ assert_eq!(journal.durable_op(), 0);
+ assert_eq!(journal.size_bytes(), (3 * PARTITION_WAL_BLOCK_SIZE) as u64);
+ assert_eq!(
+ journal.retained_bytes(),
+ 3 * journal::partition_journal::record_length(prepares[0].as_slice().len()).unwrap()
+ as u64
+ );
+ let written_after: usize = storage.state.borrow().written_bytes.values().sum();
+ assert_eq!(
+ written_after - written_before,
+ 3 * (OWNED_BATCH_BYTES + PARTITION_WAL_BLOCK_SIZE),
+ "each append writes one body and one metadata WAL record"
+ );
+ let offset = offset_prepare(4, parent);
+ journal.append(offset.clone().into_frozen()).await.unwrap();
+ prepares.push(offset);
+ for prepare in &prepares[..3] {
+ let reference = journal.segment_reference(prepare.header()).unwrap();
+ let public = Path::new(DIRECTORY).join(format!("{:020}.log", reference.start_offset));
+ let retained = Path::new(WAL).join(format!(
+ "segment-{}-{}.log",
+ reference.generation, reference.start_offset
+ ));
+ let state = storage.state.borrow();
+ let inode = state.lookup(&public).unwrap();
+ assert_eq!(inode, state.lookup(&retained).unwrap());
+ assert_eq!(state.written_bytes[&inode], OWNED_BATCH_BYTES);
+ }
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ let mut recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ assert_eq!(recovered.durable_op(), 4);
+ assert_eq!(
+ recovered.segment_checkpoint(),
+ Some(SegmentPosition::default())
+ );
+ let actual = recovered.prepares().await.unwrap();
+ assert_eq!(actual.len(), prepares.len());
+ for (actual, expected) in actual.iter().zip(&prepares) {
+ assert_eq!(actual.as_slice(), expected.as_slice());
+ }
+ recovered.checkpoint(2).await.unwrap();
+ let retained_path = Path::new(DIRECTORY).join(format!("{:020}.log", 1));
+ let reader = storage.open(&retained_path, OpenMode::Read).await.unwrap();
+ for offset in 0..2 {
+ storage
+ .remove_file(&Path::new(DIRECTORY).join(format!("{offset:020}.log")))
+ .await
+ .unwrap();
+ }
+ storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+ assert_eq!(
+ reader.read(0, OWNED_BATCH_BYTES).await.unwrap(),
+ prepares[1].as_slice()[size_of::<PrepareHeader>()..]
+ );
+ drop(recovered);
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(recovered.checkpoint_op(), 2);
+ let actual = recovered.prepares().await.unwrap();
+ assert_eq!(actual.len(), prepares.len() - 1);
+ for (actual, expected) in actual.iter().zip(&prepares[1..]) {
+ assert_eq!(actual.as_slice(), expected.as_slice());
+ }
+ });
+}
+
+fn offset_prepare(op: u64, parent: u128) -> Message<PrepareHeader> {
+ prepare(op, parent).transmute_header(|old, header: &mut PrepareHeader| {
+ *header = old;
+ header.operation = Operation::StoreConsumerOffset;
+ header.checksum = header.identity_checksum();
+ })
+}
+
+#[test]
+fn persisted_offsets_wait_for_every_buffered_body_sync_and_fence_on_failure() {
+ block_on(async {
+ const MESSAGES: u64 = 3;
+ for failed_body in [None, Some(0), Some(1), Some(2)] {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ persistence
+ .enable_segment_storage(SegmentPosition::default(), OWNED_BATCH_BYTES as u64);
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let mut parent = 0;
+ for offset in 0..MESSAGES {
+ let prepare = owned_prepare(offset + 1, parent, offset);
+ parent = prepare.header().checksum;
+ persistence.append(prepare.into_frozen(), false).unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ }
+ let offset = offset_prepare(MESSAGES + 1, parent);
+ persistence
+ .append(offset.clone().into_frozen(), true)
+ .unwrap();
+ storage.pause_file_syncs();
+ assert!(persistence.start());
+ let mut writer = Box::pin(Rc::clone(&persistence).run());
+ assert!(poll!(&mut writer).is_pending());
+ assert!(!persistence.is_durable(offset.header()));
+ assert_eq!(persistence.durable_op(), 0);
+ assert!(persistence.failure().is_none());
+ if let Some(index) = failed_body {
+ storage.fail_at(index, FaultMode::Before);
+ }
+ storage.resume();
+ writer.await;
+ assert_eq!(
+ persistence.is_durable(offset.header()),
+ failed_body.is_none()
+ );
+ assert_eq!(persistence.failure().is_some(), failed_body.is_some());
+ if failed_body.is_some() {
+ assert!(!persistence.start());
+ }
+ drop(persistence);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(
+ recovered.head(),
+ if failed_body.is_none() {
+ MESSAGES + 1
+ } else {
+ 0
+ }
+ );
+ assert_eq!(recovered.contains(offset.header()), failed_body.is_none());
+ }
+ });
+}
+
+#[test]
+fn owned_segment_fault_sweep_preserves_acknowledged_bodies_and_checkpoint_bounds() {
+ block_on(async {
+ let mut cases = 0;
+ for mutation in [
+ Mutation::Append,
+ Mutation::CertifyView,
+ Mutation::Checkpoint,
+ Mutation::Truncate,
+ Mutation::Reset,
+ Mutation::Purge,
+ ] {
+ for buffered in [false, true] {
+ let (storage, mut journal) = owned_segment_baseline(buffered).await;
+ storage.clear_trace();
+ mutate_owned_segments(&storage, &mut journal, mutation)
+ .await
+ .unwrap();
+ let trace = storage.trace();
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ for writeback in [false, true] {
+ let (storage, mut journal) = owned_segment_baseline(buffered).await;
+ storage.fail_at(cut, mode);
+ let completed =
+ mutate_owned_segments(&storage, &mut journal, mutation)
+ .await
+ .is_ok();
+ drop(journal);
+ if writeback {
+ storage.writeback();
+ }
+ storage.crash(crash);
+ let context = format!(
+ "{mutation:?} buffered={buffered} cut {cut} {operation:?} {mode:?} {crash:?} writeback={writeback}"
+ );
+ let recovered = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage,
+ )
+ .await
+ .unwrap_or_else(|error| panic!("{context}: {error}"));
+ assert_owned_segments(
+ &recovered, mutation, buffered, completed, &context,
+ )
+ .await;
+ cases += 1;
+ }
+ }
+ }
+ }
+ }
+ }
+ println!("owned segment fault cases: {cases}");
+ });
+}
+
+async fn owned_segment_baseline(
+ buffered: bool,
+) -> (SimStorage, PartitionPrepareJournal<SimStorage>) {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(SegmentPosition::default(), (2 * OWNED_BATCH_BYTES) as u64)
+ .await
+ .unwrap();
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ journal.append(first.into_frozen()).await.unwrap();
+ journal.checkpoint(1).await.unwrap();
+ journal.append_buffered(second.into_frozen()).await.unwrap();
+ if !buffered {
+ journal.sync().await.unwrap();
+ }
+ (storage, journal)
+}
+
+#[test]
+fn sealed_tail_recovery_preserves_the_public_name_at_every_crash_boundary() {
+ block_on(async {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ journal.checkpoint(2).await.unwrap();
+ drop(journal);
+ storage.clear_trace();
+ drop(
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap(),
+ );
+ let trace = storage.trace();
+ let public = Path::new("/partition/00000000000000000000.log");
+ let mut cases = 0;
+ for (cut, operation) in trace.iter().enumerate() {
+ for mode in [FaultMode::Before, FaultMode::After] {
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ for writeback in [false, true] {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ journal.checkpoint(2).await.unwrap();
+ drop(journal);
+ storage.fail_at(cut, mode);
+ let _ = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await;
+ if writeback {
+ storage.writeback();
+ }
+ storage.crash(crash);
+ let context = format!(
+ "cut {cut} {operation:?} {mode:?} {crash:?} writeback={writeback}"
+ );
+ let recovered = PartitionPrepareJournal::open_with_storage(
+ Path::new(WAL),
+ 42,
+ 7,
+ storage.clone(),
+ )
+ .await
+ .unwrap_or_else(|error| panic!("{context}: {error}"));
+ assert_eq!(recovered.checkpoint_op(), 2, "{context}");
+ let bytes = storage
+ .open(public, OpenMode::Read)
+ .await
+ .unwrap_or_else(|error| {
+ panic!("{context}: public tail missing: {error}")
+ })
+ .read(0, 2 * OWNED_BATCH_BYTES)
+ .await
+ .unwrap();
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ assert_eq!(
+ &bytes[..OWNED_BATCH_BYTES],
+ &first.as_slice()[size_of::<PrepareHeader>()..],
+ "{context}"
+ );
+ assert_eq!(
+ &bytes[OWNED_BATCH_BYTES..],
+ &second.as_slice()[size_of::<PrepareHeader>()..],
+ "{context}"
+ );
+ assert!(
+ !storage
+ .exists(&public.with_extension("log.tmp"))
+ .await
+ .unwrap(),
+ "{context}: recovery temporary must be removed"
+ );
+ cases += 1;
+ }
+ }
+ }
+ }
+ eprintln!("sealed tail recovery fault cases: {cases}");
+ });
+}
+
+#[test]
+fn sealed_tail_removed_by_retention_stays_absent_after_recovery() {
+ block_on(async {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ journal.checkpoint(2).await.unwrap();
+ drop(journal);
+ let public = Path::new("/partition/00000000000000000000.log");
+ storage.remove_file(public).await.unwrap();
+ storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+ for crash in [Crash::Process, Crash::PowerLoss] {
+ storage.crash(crash);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ assert_eq!(recovered.checkpoint_op(), 2);
+ assert_eq!(
+ recovered.prepares().await.unwrap().len(),
+ 1,
+ "the private checkpoint prepare remains repairable"
+ );
+ assert!(
+ !storage.exists(public).await.unwrap(),
+ "recovery must not resurrect retained data"
+ );
+ }
+ });
+}
+
+#[test]
+fn adjacent_segment_bodies_share_writes_bounded_by_rotation_and_iov_max() {
+ block_on(async {
+ for (count, batches_per_segment) in [(5, 2), (IOV_MAX + 1, IOV_MAX + 1)] {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ journal
+ .enable_segment_storage(
+ SegmentPosition::default(),
+ (batches_per_segment * OWNED_BATCH_BYTES) as u64,
+ )
+ .await
+ .unwrap();
+ let mut parent = 0;
+ let prepares: Vec<_> = (0..count)
+ .map(|index| {
+ let prepare = owned_prepare(1, parent, index as u64).transmute_header(
+ |original, header: &mut PrepareHeader| {
+ *header = original;
+ header.op = index as u64 + 1;
+ header.checksum = header.identity_checksum();
+ },
+ );
+ parent = prepare.header().checksum;
+ prepare.into_frozen()
+ })
+ .collect();
+ storage.clear_trace();
+ journal.append_batch_buffered(&prepares).await.unwrap();
+ journal.sync().await.unwrap();
+ let body_writes = count.div_ceil(batches_per_segment.min(IOV_MAX));
+ assert_eq!(
+ storage
+ .trace()
+ .iter()
+ .filter(|operation| **operation == StorageOperation::Write)
+ .count(),
+ body_writes + 2,
+ "body groups plus WAL and frontier writes"
+ );
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ let recovered = recovered.prepares().await.unwrap();
+ assert_eq!(recovered.len(), prepares.len());
+ for (actual, expected) in recovered.iter().zip(&prepares) {
+ assert_eq!(actual.as_slice(), expected.as_slice());
+ }
+ }
+ });
+}
+
+#[test]
+fn live_rollback_preserves_public_index_handles_for_replacement_and_checkpoint() {
+ block_on(async {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ let third = owned_prepare(3, second.header().checksum, 2);
+ journal.append(third.clone().into_frozen()).await.unwrap();
+ let log_path = Path::new("/partition/00000000000000000002.log");
+ let index_path = Path::new("/partition/00000000000000000002.index");
+ let mut index = storage.open(index_path, OpenMode::Create).await.unwrap();
+ index.write(0, b"old-index".to_vec()).await.unwrap();
+ journal.truncate_from(3).await.unwrap();
+ assert!(storage.exists(log_path).await.unwrap());
+ assert!(storage.exists(index_path).await.unwrap());
+ journal.append(third.into_frozen()).await.unwrap();
+ index.write(0, b"new-index".to_vec()).await.unwrap();
+ assert_eq!(
+ storage
+ .open(index_path, OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 9)
+ .await
+ .unwrap(),
+ b"new-index"
+ );
+ journal
+ .checkpoint_files(
+ 3,
+ &[log_path.into(), index_path.into()],
+ &[Path::new(DIRECTORY).into()],
+ &BTreeSet::new(),
+ )
+ .await
+ .unwrap();
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ let recovered = PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert_eq!(recovered.checkpoint_op(), 3);
+ assert_eq!(recovered.prepares().await.unwrap().len(), 1);
+ });
+}
+
+#[test]
+fn completed_prefix_validation_remains_available_during_a_pending_append() {
+ block_on(async {
+ for durable in [false, true] {
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ persistence
+ .enable_segment_storage(SegmentPosition::default(), (4 * OWNED_BATCH_BYTES) as u64);
+ let first = owned_prepare(1, 0, 0).into_frozen();
+ persistence.append(first.clone(), durable).unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let parent = bytemuck::checked::from_bytes::<PrepareHeader>(
+ &first.as_slice()[..size_of::<PrepareHeader>()],
+ )
+ .checksum;
+ let second = owned_prepare(2, parent, 1).into_frozen();
+ let first_prefix = std::slice::from_ref(&first);
+ let second_prefix = std::slice::from_ref(&second);
+ persistence.append(second.clone(), durable).unwrap();
+ storage.pause_writes();
+ assert!(persistence.start());
+ let mut writer = Box::pin(Rc::clone(&persistence).run());
+ assert!(poll!(&mut writer).is_pending());
+ assert_eq!(
+ persistence
+ .validate_segment_prefix(first_prefix, 0, 0, durable)
+ .unwrap(),
+ OWNED_BATCH_BYTES as u64
+ );
+ assert!(
+ persistence
+ .validate_segment_prefix(second_prefix, 0, OWNED_BATCH_BYTES as u64, durable)
+ .is_err()
+ );
+ assert!(persistence.failure().is_none());
+ storage.resume();
+ writer.await;
+ assert_eq!(
+ persistence
+ .validate_segment_prefix(second_prefix, 0, OWNED_BATCH_BYTES as u64, true)
+ .is_ok(),
+ durable
+ );
+ let metrics = persistence.take_metrics();
+ assert_eq!(metrics.disk_bytes, 2 * PARTITION_WAL_BLOCK_SIZE as u64);
+ assert_eq!(
+ metrics.retained_bytes,
+ 2 * journal::partition_journal::record_length(first.len()).unwrap() as u64
+ );
+ assert!(metrics.retained_bytes > metrics.disk_bytes);
+ assert_eq!(
+ persistence
+ .validate_segment_prefix(second_prefix, 0, OWNED_BATCH_BYTES as u64, durable)
+ .unwrap(),
+ OWNED_BATCH_BYTES as u64
+ );
+ persistence.truncate_from(2);
+ assert!(
+ persistence
+ .validate_segment_prefix(second_prefix, 0, OWNED_BATCH_BYTES as u64, durable)
+ .is_err()
+ );
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ assert!(
+ persistence
+ .validate_segment_prefix(first_prefix, 0, 0, durable)
+ .is_ok()
+ );
+ persistence.reset_with_segments(
+ 2,
+ None,
+ None,
+ Some((
+ SegmentPosition {
+ start_offset: 2,
+ length: 0,
+ next_offset: 2,
+ },
+ (4 * OWNED_BATCH_BYTES) as u64,
+ )),
+ );
+ assert!(
+ persistence
+ .validate_segment_prefix(first_prefix, 0, 0, durable)
+ .is_err()
+ );
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.failure().is_none());
+ }
+ });
+}
+
+#[test]
+fn failed_vectored_body_group_never_publishes_a_partial_prefix() {
+ block_on(async {
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ let third = owned_prepare(3, second.header().checksum, 2);
+ let fourth = owned_prepare(4, third.header().checksum, 3);
+ let prepares = [third.into_frozen(), fourth.into_frozen()];
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ storage.clear_trace();
+ journal.append_batch_buffered(&prepares).await.unwrap();
+ journal.sync().await.unwrap();
+ let operations = storage.trace().len();
+ for cut in 0..operations {
+ for mode in [FaultMode::Before, FaultMode::After, FaultMode::TornWrite] {
+ let (storage, mut journal) = owned_segment_baseline(false).await;
+ storage.fail_at(cut, mode);
+ let acknowledged = journal.append_batch_buffered(&prepares).await.is_ok()
+ && journal.sync().await.is_ok();
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage)
+ .await
+ .unwrap();
+ assert!(matches!(recovered.head(), 2 | 4), "cut {cut}, {mode:?}");
+ if acknowledged {
+ assert_eq!(recovered.head(), 4, "cut {cut}, {mode:?}");
+ }
+ let actual = recovered.prepares().await.unwrap();
+ assert_eq!(actual[0].as_slice(), first.as_slice());
+ assert_eq!(actual[1].as_slice(), second.as_slice());
+ for (actual, expected) in actual[2..].iter().zip(&prepares) {
+ assert_eq!(
+ actual.as_slice(),
+ expected.as_slice(),
+ "cut {cut}, {mode:?}"
+ );
+ }
+ }
+ }
+ });
+}
+
+async fn mutate_owned_segments(
+ storage: &SimStorage,
+ journal: &mut PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+) -> io::Result<()> {
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ match mutation {
+ Mutation::Append => {
+ journal
+ .append(owned_prepare(3, second.header().checksum, 2).into_frozen())
+ .await
+ }
+ Mutation::Checkpoint => journal.checkpoint(2).await,
+ Mutation::Truncate => journal.truncate_from(2).await,
+ Mutation::CertifyView => {
+ journal
+ .certify_log_view(1, 2, second.header().checksum)
+ .await
+ }
+ Mutation::Reset => {
+ let public = Path::new(DIRECTORY).join(format!("{:020}.log", 0));
+ storage.remove_file(&public).await?;
+ storage
+ .open(&public, OpenMode::Create)
+ .await?
+ .sync()
+ .await?;
+ storage.sync_directory(Path::new(DIRECTORY)).await?;
+ journal
+ .reset_with_segment_checkpoint(
+ 2,
+ Some(second.header().checksum),
+ Some(second.into_frozen()),
+ SegmentPosition::default(),
+ (2 * OWNED_BATCH_BYTES) as u64,
+ )
+ .await
+ }
+ Mutation::Purge => {
+ journal.mark_purge(1, 2).await?;
+ journal
+ .append(owned_prepare(3, second.header().checksum, 0).into_frozen())
+ .await
+ }
+ }
+}
+
+async fn assert_owned_segments(
+ journal: &PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+ buffered: bool,
+ completed: bool,
+ context: &str,
+) {
+ let first = owned_prepare(1, 0, 0);
+ let second = owned_prepare(2, first.header().checksum, 1);
+ let third = owned_prepare(
+ 3,
+ second.header().checksum,
+ if matches!(mutation, Mutation::Purge) {
+ 0
+ } else {
+ 2
+ },
+ );
+ let expected = [first, second, third];
+ let checkpoint = journal.segment_checkpoint().unwrap();
+ let checkpointed = match mutation {
+ Mutation::Checkpoint if journal.checkpoint_op() == 2 => 2,
+ Mutation::Purge if journal.purge_marker() == (1, 2) => 0,
+ Mutation::Reset if journal.checkpoint_op() == 2 => 0,
+ _ => 1,
+ };
+ assert_eq!(
+ checkpoint,
+ SegmentPosition {
+ start_offset: 0,
+ length: checkpointed * OWNED_BATCH_BYTES as u64,
+ next_offset: checkpointed
+ },
+ "{context}"
+ );
+ let expected_head = match mutation {
+ Mutation::Append | Mutation::Purge => 3,
+ Mutation::Truncate => 1,
+ _ => 2,
+ };
+ if completed {
+ assert_eq!(journal.head(), expected_head, "{context}");
+ }
+ let baseline_head = if buffered { 1 } else { 2 };
+ assert!(
+ [
+ baseline_head,
+ if matches!(mutation, Mutation::Purge) {
+ 2
+ } else {
+ expected_head
+ },
+ expected_head
+ ]
+ .contains(&journal.head()),
+ "{context}"
+ );
+ if completed && matches!(mutation, Mutation::Checkpoint) {
+ assert_eq!(checkpointed, 2, "{context}");
+ }
+ if completed && matches!(mutation, Mutation::Purge) {
+ assert_eq!(checkpointed, 0, "{context}");
+ }
+ let prepares = journal.prepares().await.unwrap();
+ let expected_ops: Vec<_> = (journal.checkpoint_op()..=journal.head()).collect();
+ assert_eq!(
+ prepares
+ .iter()
+ .map(|prepare| prepare.header().op)
+ .collect::<Vec<_>>(),
+ expected_ops,
+ "{context}"
+ );
+ for prepare in &prepares {
+ let index = usize::try_from(prepare.header().op - 1).unwrap();
+ assert_eq!(prepare.as_slice(), expected[index].as_slice(), "{context}");
+ assert_eq!(
+ journal.segment_reference(prepare.header()).is_some(),
+ prepare.header().op > journal.purge_marker().1,
+ "{context}"
+ );
+ }
+ assert_eq!(
+ journal.size_bytes(),
+ prepares
+ .iter()
+ .map(|prepare| {
+ if prepare.header().op <= journal.purge_marker().1 {
+ journal::partition_journal::record_length(prepare.as_slice().len()).unwrap()
+ as u64
+ } else {
+ PARTITION_WAL_BLOCK_SIZE as u64
+ }
+ })
+ .sum::<u64>(),
+ "{context}"
+ );
+}
+
+fn owned_prepare(op: u64, parent: u128, offset: u64) -> Message<PrepareHeader> {
+ let payload = vec![
+ u8::try_from(op).unwrap();
+ OWNED_BATCH_BYTES - BATCH_HEADER_SIZE - BATCH_MESSAGE_HEADER_SIZE
+ ];
+ let mut messages = IggyMessages::with_capacity(1);
+ messages.push(IggyMessage {
+ header: IggyMessageHeader {
+ id: u128::from(op),
+ payload_length: u32::try_from(payload.len()).unwrap(),
+ ..Default::default()
+ },
+ payload: payload.into(),
+ user_headers: None,
+ });
+ let namespace = IggyNamespace::new(0, 0, 42);
+ assert_eq!(namespace.inner(), 42);
+ let mut batch = SendMessagesOwned::from_messages(namespace, &messages).unwrap();
+ batch.header.base_offset = offset;
+ batch.header.batch_checksum = batch.header.checksum_for_blob(&batch.blob);
+ let mut body = vec![0; BATCH_HEADER_SIZE + batch.blob.len()];
+ batch.header.encode_into(&mut body[..BATCH_HEADER_SIZE]);
+ body[BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ assert_eq!(body.len(), OWNED_BATCH_BYTES);
+ prepare_with_payload(op, parent, &body).transmute_header(
+ |original, header: &mut PrepareHeader| {
+ *header = original;
+ header.checksum_body = 0;
+ header.checksum = header.identity_checksum();
+ },
+ )
+}
+
+async fn referenced_baseline() -> (SimStorage, PartitionPrepareJournal<SimStorage>) {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ append_referenced(&storage, &mut journal, &first, 0, 0)
+ .await
+ .unwrap();
+ append_referenced(&storage, &mut journal, &second, 0, 1)
+ .await
+ .unwrap();
+ (storage, journal)
+}
+
+async fn append_referenced(
+ storage: &SimStorage,
+ journal: &mut PartitionPrepareJournal<SimStorage>,
+ prepare: &Message<PrepareHeader>,
+ generation: u64,
+ start_offset: u64,
+) -> io::Result<()> {
+ let body = &prepare.as_slice()[size_of::<PrepareHeader>()..];
+ let path = Path::new(DIRECTORY).join(format!("{start_offset:020}.log"));
+ let mut file = storage.open(&path, OpenMode::Create).await?;
+ file.write(0, body.to_vec()).await?;
+ file.sync().await?;
+ let reference = SegmentReference {
+ generation,
+ start_offset,
+ position: 0,
+ length: body.len() as u64,
+ };
+ journal
+ .append_batch_referenced_buffered(&[prepare.clone().into_frozen()], &[Some(reference)])
+ .await?;
+ journal.sync().await
+}
+
+async fn mutate_referenced(
+ storage: &SimStorage,
+ journal: &mut PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+) -> io::Result<()> {
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ match mutation {
+ Mutation::Append => {
+ append_referenced(
+ storage,
+ journal,
+ &prepare(3, second.header().checksum),
+ 0,
+ 2,
+ )
+ .await
+ }
+ Mutation::CertifyView => {
+ journal
+ .certify_log_view(2, 2, second.header().checksum)
+ .await
+ }
+ Mutation::Checkpoint => journal.checkpoint(2).await,
+ Mutation::Truncate => journal.truncate_from(2).await,
+ Mutation::Reset => journal.reset(7, None).await,
+ Mutation::Purge => {
+ journal.mark_purge(1, 2).await?;
+ for offset in [0, 1] {
+ storage
+ .remove_file(&Path::new(DIRECTORY).join(format!("{offset:020}.log")))
+ .await?;
+ }
+ storage.sync_directory(Path::new(DIRECTORY)).await?;
+ append_referenced(
+ storage,
+ journal,
+ &prepare(3, second.header().checksum),
+ 1,
+ 0,
+ )
+ .await
+ }
+ }
+}
+
+async fn assert_referenced_recovery(
+ journal: &PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+ completed: bool,
+ context: &str,
+) {
+ match mutation {
+ Mutation::Append | Mutation::Purge => {
+ assert!((2..=3).contains(&journal.head()), "{context}");
+ if completed {
+ assert_eq!(journal.head(), 3, "{context}");
+ }
+ }
+ Mutation::Truncate => {
+ assert!((1..=2).contains(&journal.head()), "{context}");
+ if completed {
+ assert_eq!(journal.head(), 1, "{context}");
+ }
+ }
+ Mutation::Reset => {
+ assert!([2, 7].contains(&journal.head()), "{context}");
+ if completed {
+ assert_eq!(journal.head(), 7, "{context}");
+ }
+ }
+ Mutation::Checkpoint => {
+ assert_eq!(journal.head(), 2, "{context}");
+ assert!([0, 2].contains(&journal.checkpoint_op()), "{context}");
+ if completed {
+ assert_eq!(journal.checkpoint_op(), 2, "{context}");
+ }
+ }
+ Mutation::CertifyView => {
+ assert_eq!(journal.head(), 2, "{context}");
+ if completed {
+ assert_eq!(journal.certified_log_view(), Some(2), "{context}");
+ }
+ }
+ }
+ let first = prepare(1, 0);
+ let second = prepare(2, first.header().checksum);
+ let third = prepare(3, second.header().checksum);
+ let expected = [first, second, third];
+ let recovered = journal.prepares().await.unwrap();
+ let expected_ops: Vec<_> = if matches!(mutation, Mutation::Reset) && journal.head() == 7 {
+ assert_eq!(journal.checkpoint_op(), 7, "{context}");
+ Vec::new()
+ } else if matches!(mutation, Mutation::Checkpoint) && journal.checkpoint_op() == 2 {
+ vec![2]
+ } else {
+ assert_eq!(journal.checkpoint_op(), 0, "{context}");
+ (1..=journal.head()).collect()
+ };
+ assert_eq!(
+ recovered
+ .iter()
+ .map(|entry| entry.header().op)
+ .collect::<Vec<_>>(),
+ expected_ops,
+ "{context}",
+ );
+ if matches!(mutation, Mutation::Purge) {
+ assert!(
+ [(0, 0), (1, 2)].contains(&journal.purge_marker()),
+ "{context}"
+ );
+ if completed || journal.head() == 3 {
+ assert_eq!(journal.purge_marker(), (1, 2), "{context}");
+ }
+ }
+ assert_eq!(
+ journal.size_bytes(),
+ (recovered.len() * PARTITION_WAL_BLOCK_SIZE) as u64,
+ "{context}"
+ );
+ for entry in recovered {
+ let index = usize::try_from(entry.header().op - 1).unwrap();
+ assert_eq!(entry.as_slice(), expected[index].as_slice(), "{context}");
+ }
+}
+
+async fn mutate(
+ storage: &SimStorage,
+ journal: &mut PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+) -> io::Result<()> {
+ match mutation {
+ Mutation::Append => {
+ let entries = journal.prepares().await?;
+ let last = bytemuck::checked::from_bytes::<PrepareHeader>(
+ &entries.last().unwrap().as_slice()[..size_of::<PrepareHeader>()],
+ );
+ journal
+ .append(prepare(4, last.checksum).into_frozen())
+ .await
+ }
+ Mutation::CertifyView => {
+ let entries = journal.prepares().await?;
+ let last = bytemuck::checked::from_bytes::<PrepareHeader>(
+ &entries.last().unwrap().as_slice()[..size_of::<PrepareHeader>()],
+ );
+ let next = prepare(4, last.checksum);
+ let checksum = next.header().checksum;
+ journal.append_buffered(next.into_frozen()).await?;
+ journal.certify_log_view(2, 4, checksum).await
+ }
+ Mutation::Checkpoint => {
+ replace(storage, Path::new("/partition/materialized"), b"1,2").await?;
+ journal.checkpoint(2).await
+ }
+ Mutation::Truncate => journal.truncate_from(3).await,
+ Mutation::Reset => {
+ replace(storage, Path::new("/partition/materialized"), b"1-7").await?;
+ journal.reset(7, None).await
+ }
+ Mutation::Purge => {
+ journal.mark_purge(9, 3).await?;
+ storage.remove_file(Path::new("/partition/state")).await?;
+ storage.sync_directory(Path::new(DIRECTORY)).await?;
+ replace(storage, Path::new("/partition/purge.gen"), b"9").await
+ }
+ }
+}
+
+async fn install(
+ storage: &SimStorage,
+ journal: &mut PartitionPrepareJournal<SimStorage>,
+) -> io::Result<()> {
+ install_backup::begin_with_storage(Path::new(DIRECTORY), storage).await?;
+ replace(storage, Path::new("/partition/state"), b"new").await?;
+ for path in MATERIALIZED_FILES {
+ replace(storage, Path::new(path), b"new").await?;
+ }
+ journal.reset(7, None).await?;
+ install_backup::finish_with_storage(Path::new(DIRECTORY), storage).await
+}
+
+async fn replace(storage: &SimStorage, path: &Path, bytes: &[u8]) -> io::Result<()> {
+ let temporary = path.with_extension("tmp");
+ let mut file = storage.open(&temporary, OpenMode::Create).await?;
+ file.write(0, bytes.to_vec()).await?;
+ file.sync().await?;
+ storage.rename(&temporary, path).await?;
+ storage.sync_directory(path.parent().unwrap()).await
+}
+
+async fn assert_recovery(
+ storage: &SimStorage,
+ journal: &PartitionPrepareJournal<SimStorage>,
+ mutation: Mutation,
+ completed: bool,
+) {
+ match mutation {
+ Mutation::Append => {
+ assert!((3..=4).contains(&journal.head()));
+ if completed {
+ assert_eq!(journal.head(), 4);
+ }
+ }
+ Mutation::CertifyView => {
+ assert!((3..=4).contains(&journal.head()));
+ if completed {
+ assert_eq!(journal.certified_log_view(), Some(2));
+ }
+ if journal.certified_log_view() == Some(2) {
+ assert_eq!(journal.head(), 4);
+ }
+ }
+ Mutation::Checkpoint => {
+ assert_eq!(journal.head(), 3);
+ assert!([0, 2].contains(&journal.checkpoint_op()));
+ if journal.checkpoint_op() == 2 {
+ assert_eq!(
+ storage
+ .open(Path::new("/partition/materialized"), OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 3)
+ .await
+ .unwrap(),
+ b"1,2"
+ );
+ }
+ if completed {
+ assert_eq!(journal.checkpoint_op(), 2);
+ }
+ }
+ Mutation::Truncate => {
+ assert!([2, 3].contains(&journal.head()));
+ if completed {
+ assert_eq!(journal.head(), 2);
+ }
+ }
+ Mutation::Reset => {
+ assert!([3, 7].contains(&journal.head()));
+ if journal.head() == 7 {
+ assert_eq!(journal.checkpoint_op(), 7);
+ assert_eq!(
+ storage
+ .open(Path::new("/partition/materialized"), OpenMode::Read)
+ .await
+ .unwrap()
+ .read(0, 3)
+ .await
+ .unwrap(),
+ b"1-7"
+ );
+ }
+ if completed {
+ assert_eq!(journal.head(), 7);
+ }
+ }
+ Mutation::Purge => {
+ assert_eq!(journal.head(), 3);
+ assert!([(0, 0), (9, 3)].contains(&journal.purge_marker()));
+ if storage
+ .exists(Path::new("/partition/purge.gen"))
+ .await
+ .unwrap()
+ {
+ assert_eq!(journal.purge_marker(), (9, 3));
+ assert!(!storage.exists(Path::new("/partition/state")).await.unwrap());
+ }
+ if completed {
+ assert_eq!(journal.purge_marker(), (9, 3));
+ assert!(
+ storage
+ .exists(Path::new("/partition/purge.gen"))
+ .await
+ .unwrap()
+ );
+ }
+ }
+ }
+ let entries = journal.prepares().await.unwrap();
+ assert_eq!(
+ entries.len() as u64,
+ journal.head() - journal.checkpoint_op()
+ + u64::from(matches!(mutation, Mutation::Checkpoint) && journal.checkpoint_op() > 0)
+ );
+}
+
+fn prepare(op: u64, parent: u128) -> Message<PrepareHeader> {
+ prepare_with_payload(op, parent, &vec![u8::try_from(op).unwrap(); 12 * 1024])
+}
+
+fn prepare_with_payload(op: u64, parent: u128, payload: &[u8]) -> Message<PrepareHeader> {
+ let mut buffer = Owned::<4096>::zeroed(size_of::<PrepareHeader>() + payload.len());
+ buffer.as_mut_slice()[size_of::<PrepareHeader>()..].copy_from_slice(payload);
+ let length = buffer.as_slice().len();
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+ &mut buffer.as_mut_slice()[..size_of::<PrepareHeader>()],
+ );
+ header.command = Command::Prepare;
+ header.operation = Operation::SendMessages;
+ header.group = 42;
+ header.op = op;
+ header.parent = parent;
+ header.size = u32::try_from(length).unwrap();
+ header.checksum_body = u128::from(XxHash3_64::oneshot(payload));
+ header.checksum = header.identity_checksum();
+ Message::try_from(buffer).unwrap()
+}
diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json
index 2847ff8..1cfb89c 100644
--- a/examples/node/package-lock.json
+++ b/examples/node/package-lock.json
@@ -23,7 +23,7 @@
},
"../../foreign/node": {
"name": "apache-iggy",
- "version": "0.10.0-edge.6",
+ "version": "0.10.0-edge.7",
"license": "Apache-2.0",
"dependencies": {
"@node-rs/xxhash": "1.7.7",
diff --git a/examples/python/uv.lock b/examples/python/uv.lock
index f27d0c6..b207eab 100644
--- a/examples/python/uv.lock
+++ b/examples/python/uv.lock
@@ -8,7 +8,7 @@
[[package]]
name = "apache-iggy"
-version = "0.9.0.dev7"
+version = "0.9.0.dev8"
source = { directory = "../../foreign/python" }
[package.metadata]
diff --git a/foreign/cpp/Cargo.toml b/foreign/cpp/Cargo.toml
index bb4802d..489d51c 100644
--- a/foreign/cpp/Cargo.toml
+++ b/foreign/cpp/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy-cpp"
-version = "0.1.2"
+version = "0.1.3"
edition = "2024"
[package.metadata.cargo-machete]
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index ce679c2..3401589 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -17,7 +17,7 @@
module(
name = "iggy_cpp",
- version = "0.1.2",
+ version = "0.1.3",
)
bazel_dep(name = "rules_cc", version = "0.2.22")
diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp
index 5938152..f19ee03 100644
--- a/foreign/cpp/include/iggy.hpp
+++ b/foreign/cpp/include/iggy.hpp
@@ -387,6 +387,18 @@
} // namespace detail
+enum class Durability { Replicated, Persisted };
+
+constexpr std::string_view to_string(const Durability durability) {
+ switch (durability) {
+ case Durability::Replicated:
+ return "replicated";
+ case Durability::Persisted:
+ return "persisted";
+ }
+ throw std::invalid_argument("Unknown durability");
+}
+
/**
* @brief Creates topic option entries for `Client::create_topic(...)`.
*
@@ -420,13 +432,25 @@
}
/**
- * @brief Choose whether writes to this topic's partitions are fsynced.
+ * @brief Choose the message completion policy.
*
- * @param enabled Whether partition writes are fsynced.
+ * @param value The policy, defaulting to Replicated.
* @return Encoded topic option entry.
*/
- static iggy::ffi::HeaderEntry EnforceFsync(const bool enabled) {
- return detail::to_option_entry("enforce_fsync", iggy::ffi::HeaderKind::Bool, detail::to_bool_bytes(enabled));
+ static iggy::ffi::HeaderEntry Durability(const iggy::Durability value = iggy::Durability::Replicated) {
+ return detail::to_option_entry("durability", iggy::ffi::HeaderKind::String,
+ detail::to_key_bytes(to_string(value)));
+ }
+
+ /**
+ * @brief Choose explicit offset completion independently of message durability.
+ * @param value The policy, defaulting to Replicated.
+ * @return Encoded topic option entry.
+ */
+ static iggy::ffi::HeaderEntry ConsumerOffsetDurability(
+ const iggy::Durability value = iggy::Durability::Replicated) {
+ return detail::to_option_entry("consumer_offset_durability", iggy::ffi::HeaderKind::String,
+ detail::to_key_bytes(to_string(value)));
}
/**
diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs
index 002ead5..a830ce3 100644
--- a/foreign/cpp/src/client.rs
+++ b/foreign/cpp/src/client.rs
@@ -104,9 +104,8 @@
);
if config.has_reconnection_interval {
let reconnection_interval =
- RustNonZeroIggyDuration::try_from(config.reconnection_interval_micros).map_err(
- |error| format!("Invalid reconnection interval: {error}"),
- )?;
+ RustNonZeroIggyDuration::try_from(config.reconnection_interval_micros)
+ .map_err(|error| format!("Invalid reconnection interval: {error}"))?;
builder = builder.with_reconnection_interval(reconnection_interval);
}
if config.has_reestablish_after {
@@ -481,12 +480,32 @@
})?,
};
- let raw = crate::type_conversion::ffi_options_to_raw(options)
+ let mut raw = crate::type_conversion::ffi_options_to_raw(options)
.map_err(|error| format!("Could not create topic '{topic_name}': {error}"))?;
- // `None` is what tells admission to resolve the server default, so the
- // sentinels the string parsers produce must collapse back to it.
+ // Both completion policies are sent explicitly and default independently.
+ let durability = raw
+ .remove("durability")
+ .map(|value| value.parse::<iggy::prelude::Durability>())
+ .transpose()
+ .map_err(|_| {
+ iggy::prelude::IggyError::InvalidOptionValue("durability".to_owned()).to_string()
+ })?
+ .unwrap_or_default();
+ let consumer_offset_durability = raw
+ .remove("consumer_offset_durability")
+ .map(|value| value.parse::<iggy::prelude::Durability>())
+ .transpose()
+ .map_err(|_| {
+ iggy::prelude::IggyError::InvalidOptionValue(
+ "consumer_offset_durability".to_owned(),
+ )
+ .to_string()
+ })?
+ .unwrap_or_default();
let options = TopicCreateOptions {
+ durability,
+ consumer_offset_durability,
partitions_count: Some(partitions_count),
compression_algorithm: (rust_compression_algorithm
!= RustCompressionAlgorithm::default())
diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs
index 496b072..d2773f7 100644
--- a/foreign/cpp/src/lib.rs
+++ b/foreign/cpp/src/lib.rs
@@ -185,9 +185,8 @@
/// the same batch at a lower offset, so this never identifies a batch
/// uniquely.
///
- /// A batch is confirmed once it is committed in memory, not once it is
- /// fsynced. A crash-restart can stamp a later batch with an offset a
- /// client has already recorded.
+ /// Confirmation follows VSR quorum commit. Persisted message durability
+ /// also requires recoverable stable-storage copies on the quorum.
base_offset: u64,
}
diff --git a/foreign/cpp/tests/e2e/topic.cpp b/foreign/cpp/tests/e2e/topic.cpp
index 5bc3bda..e3f3366 100644
--- a/foreign/cpp/tests/e2e/topic.cpp
+++ b/foreign/cpp/tests/e2e/topic.cpp
@@ -240,8 +240,8 @@
TrackStream(stream_name);
rust::Vec<iggy::ffi::HeaderEntry> options;
- options.push_back(make_header_entry(make_header_field(iggy::ffi::HeaderKind::String, to_payload("enforce_fsync")),
- make_header_field(iggy::ffi::HeaderKind::String, to_payload("true"))));
+ options.push_back(make_header_entry(make_header_field(iggy::ffi::HeaderKind::String, to_payload("durability")),
+ make_header_field(iggy::ffi::HeaderKind::String, to_payload("persisted"))));
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name, 1, "none", "server_default",
0, "server_default", std::move(options)));
@@ -250,11 +250,9 @@
// Admission re-encodes the block from its own parse, so a value comes back
// in its key's catalog kind rather than in the kind that was sent.
- rust::Vec<std::uint8_t> enforce_fsync_enabled;
- enforce_fsync_enabled.push_back(1);
EXPECT_TRUE(has_header(topic_details.options, static_cast<std::uint8_t>(iggy::ffi::HeaderKind::String),
- to_payload("enforce_fsync"), static_cast<std::uint8_t>(iggy::ffi::HeaderKind::Bool),
- enforce_fsync_enabled));
+ to_payload("durability"), static_cast<std::uint8_t>(iggy::ffi::HeaderKind::String),
+ to_payload("persisted")));
EXPECT_FALSE(topic_details.derived_options.empty());
std::unordered_set<std::string> derived_option_keys;
@@ -262,7 +260,7 @@
derived_option_keys.insert(std::string(derived_option.key.value.begin(), derived_option.key.value.end()));
}
EXPECT_EQ(derived_option_keys.count("max_topic_size"), 1u);
- EXPECT_EQ(derived_option_keys.count("enforce_fsync"), 0u);
+ EXPECT_EQ(derived_option_keys.count("durability"), 0u);
rust::Vec<iggy::ffi::HeaderEntry> unknown_options;
unknown_options.push_back(
@@ -291,7 +289,7 @@
rust::Vec<iggy::ffi::HeaderEntry> options;
options.push_back(iggy::TopicOption::SegmentSize(segment_size_bytes));
- options.push_back(iggy::TopicOption::EnforceFsync(true));
+ options.push_back(iggy::TopicOption::Durability(iggy::Durability::Persisted));
options.push_back(iggy::TopicOption::MessagesRequiredToSave(messages_required_to_save));
options.push_back(iggy::TopicOption::SizeOfMessagesRequiredToSave(size_of_messages_required_to_save));
options.push_back(iggy::TopicOption::PreallocateSegments(false));
@@ -309,7 +307,8 @@
EXPECT_TRUE(has_header(topic_details.options, key_kind, to_payload("segment_size"), uint64_kind,
little_endian_bytes(segment_size_bytes, 8)));
- EXPECT_TRUE(has_header(topic_details.options, key_kind, to_payload("enforce_fsync"), bool_kind, bool_bytes(true)));
+ EXPECT_TRUE(
+ has_header(topic_details.options, key_kind, to_payload("durability"), key_kind, to_payload("persisted")));
EXPECT_TRUE(has_header(topic_details.options, key_kind, to_payload("messages_required_to_save"), uint32_kind,
little_endian_bytes(messages_required_to_save, 4)));
EXPECT_TRUE(has_header(topic_details.options, key_kind, to_payload("size_of_messages_required_to_save"),
@@ -334,18 +333,18 @@
ASSERT_NO_THROW({ topic_options = client->describe_options("topic"); });
const iggy::ffi::OptionSpec *segment_size = nullptr;
- bool found_enforce_fsync = false;
+ bool found_durability = false;
for (const auto &option : topic_options) {
const std::string key = static_cast<std::string>(option.key);
if (key == "segment_size") {
segment_size = &option;
- } else if (key == "enforce_fsync") {
- found_enforce_fsync = true;
+ } else if (key == "durability") {
+ found_durability = true;
}
}
ASSERT_NE(segment_size, nullptr) << "Topic catalog is missing segment_size";
- EXPECT_TRUE(found_enforce_fsync) << "Topic catalog is missing enforce_fsync";
+ EXPECT_TRUE(found_durability) << "Topic catalog is missing durability";
EXPECT_EQ(segment_size->kind, static_cast<std::uint8_t>(iggy::ffi::HeaderKind::Uint64));
EXPECT_FALSE(segment_size->default_value.empty());
EXPECT_FALSE(segment_size->description.empty());
diff --git a/foreign/cpp/tests/unit/unit_tests.cpp b/foreign/cpp/tests/unit/unit_tests.cpp
index 18db58f..0f215d5 100644
--- a/foreign/cpp/tests/unit/unit_tests.cpp
+++ b/foreign/cpp/tests/unit/unit_tests.cpp
@@ -125,19 +125,16 @@
EXPECT_EQ(option_value_bytes(option), (std::vector<std::uint8_t>{0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}));
}
-TEST(TopicOptionTest, EnforceFsyncEncodesSingleBoolByte) {
- const auto enabled = iggy::TopicOption::EnforceFsync(true);
-
- EXPECT_EQ(enabled.key.kind, kind_code(iggy::ffi::HeaderKind::String));
- EXPECT_EQ(option_key(enabled), "enforce_fsync");
- EXPECT_EQ(enabled.value.kind, kind_code(iggy::ffi::HeaderKind::Bool));
- EXPECT_EQ(option_value_bytes(enabled), (std::vector<std::uint8_t>{1}));
-
- const auto disabled = iggy::TopicOption::EnforceFsync(false);
-
- EXPECT_EQ(option_key(disabled), "enforce_fsync");
- EXPECT_EQ(disabled.value.kind, kind_code(iggy::ffi::HeaderKind::Bool));
- EXPECT_EQ(option_value_bytes(disabled), (std::vector<std::uint8_t>{0}));
+TEST(TopicOptionTest, DurabilityEncodesCanonicalStrings) {
+ const auto persisted = iggy::TopicOption::Durability(iggy::Durability::Persisted);
+ EXPECT_EQ(option_key(persisted), "durability");
+ EXPECT_EQ(persisted.value.kind, kind_code(iggy::ffi::HeaderKind::String));
+ EXPECT_EQ(option_value_bytes(persisted), (std::vector<std::uint8_t>{'p', 'e', 'r', 's', 'i', 's', 't', 'e', 'd'}));
+ const auto offset = iggy::TopicOption::ConsumerOffsetDurability();
+ EXPECT_EQ(option_key(offset), "consumer_offset_durability");
+ EXPECT_EQ(option_value_bytes(offset),
+ (std::vector<std::uint8_t>{'r', 'e', 'p', 'l', 'i', 'c', 'a', 't', 'e', 'd'}));
+ EXPECT_THROW(iggy::TopicOption::Durability(static_cast<iggy::Durability>(99)), std::invalid_argument);
}
TEST(TopicOptionTest, MessagesRequiredToSaveEncodesLittleEndianUint32) {
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs
index e6cafc9..924f904 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs
@@ -226,7 +226,7 @@
.WithName($"iggy-vsr-{_name}-{node}-{_idSuffix}")
.WithEnvironment("IGGY_ROOT_USERNAME", "iggy")
.WithEnvironment("IGGY_ROOT_PASSWORD", "iggy")
- .WithEnvironment("IGGY_SYSTEM_PATH", $"local_data_vsr_{node}")
+ .WithEnvironment("IGGY_PATH", $"local_data_vsr_{node}")
.WithEnvironment("IGGY_TCP_ADDRESS", $"0.0.0.0:{ports.Tcp}")
.WithEnvironment("IGGY_HTTP_ADDRESS", $"0.0.0.0:{ports.Http}")
.WithEnvironment("IGGY_QUIC_ADDRESS", $"0.0.0.0:{ports.Quic}")
@@ -259,7 +259,7 @@
if (_traceLogs)
{
builder = builder
- .WithEnvironment("IGGY_SYSTEM_LOGGING_LEVEL", "trace")
+ .WithEnvironment("IGGY_LOGGING_LEVEL", "trace")
.WithEnvironment("RUST_LOG", "trace");
}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/OptionsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/OptionsTests.cs
index 375dab2..d282aed 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/OptionsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/OptionsTests.cs
@@ -32,7 +32,8 @@
"message_expiry",
"max_topic_size",
"segment_size",
- "enforce_fsync",
+ "durability",
+ "consumer_offset_durability",
"messages_required_to_save",
"size_of_messages_required_to_save",
"preallocate_segments"
@@ -78,8 +79,10 @@
specs["segment_size"].Kind.ShouldBe(HeaderKind.Uint64);
BitConverter.ToUInt64(specs["segment_size"].DefaultValue).ShouldBe(1024UL * 1024 * 1024);
- specs["enforce_fsync"].Kind.ShouldBe(HeaderKind.Bool);
- specs["enforce_fsync"].DefaultValue.ShouldBe([0]);
+ specs["durability"].Kind.ShouldBe(HeaderKind.String);
+ specs["consumer_offset_durability"].Kind.ShouldBe(HeaderKind.String);
+ Encoding.UTF8.GetString(specs["durability"].DefaultValue).ShouldBe("replicated");
+ Encoding.UTF8.GetString(specs["consumer_offset_durability"].DefaultValue).ShouldBe("replicated");
specs["messages_required_to_save"].Kind.ShouldBe(HeaderKind.Uint32);
BitConverter.ToUInt32(specs["messages_required_to_save"].DefaultValue).ShouldBe(1024u);
@@ -127,7 +130,7 @@
var typedKeys = new TopicOptions
{
SegmentSize = 1,
- EnforceFsync = true,
+ Durability = Apache.Iggy.Enums.Durability.Persisted,
MessagesRequiredToSave = 1,
SizeOfMessagesRequiredToSave = 1,
PreallocateSegments = true
@@ -148,7 +151,7 @@
IReadOnlyList<OptionSpec> catalog = await client.DescribeOptionsAsync(OptionsScope.Topic);
Dictionary<string, HeaderValue> options = new TopicOptions
{
- EnforceFsync = true,
+ Durability = Apache.Iggy.Enums.Durability.Persisted,
MessagesRequiredToSave = 7
}.ToDictionary();
@@ -160,14 +163,14 @@
topic.DerivedOptions.ShouldNotBeNull();
HashSet<string> explicitKeys = topic.Options!.Keys.Select(key => key.AsString()).ToHashSet();
- explicitKeys.ShouldContain("enforce_fsync");
+ explicitKeys.ShouldContain("durability");
explicitKeys.ShouldContain("messages_required_to_save");
- AsBool(topic.Options.Single(kv => kv.Key.AsString() == "enforce_fsync").Value).ShouldBeTrue();
+ topic.Options.Single(kv => kv.Key.AsString() == "durability").Value.ToString().ShouldBe("persisted");
topic.Options.Single(kv => kv.Key.AsString() == "messages_required_to_save").Value.ToString()
.ShouldBe("7");
HashSet<string> derivedKeys = topic.DerivedOptions!.Keys.Select(key => key.AsString()).ToHashSet();
- derivedKeys.ShouldNotContain("enforce_fsync");
+ derivedKeys.ShouldNotContain("durability");
derivedKeys.ShouldNotContain("messages_required_to_save");
derivedKeys.ShouldContain("segment_size");
@@ -176,7 +179,7 @@
var fetched = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String("opts-topic"));
fetched.ShouldNotBeNull();
- AsBool(fetched.Options!.Single(kv => kv.Key.AsString() == "enforce_fsync").Value).ShouldBeTrue();
+ fetched.Options!.Single(kv => kv.Key.AsString() == "durability").Value.ToString().ShouldBe("persisted");
fetched.DerivedOptions!.Keys.Select(key => key.AsString()).ShouldContain("segment_size");
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/SendMessagesResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/SendMessagesResponse.cs
index 199bdc4..fe0b139 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/SendMessagesResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/SendMessagesResponse.cs
@@ -29,8 +29,8 @@
/// at a lower offset, so the value never implies uniqueness.
/// </item>
/// <item>
-/// A batch is confirmed once it is committed in memory, not once it is fsynced. A
-/// crash-restart can stamp a later batch with an offset a client has already recorded.
+/// Confirmation follows VSR quorum commit. Persisted message durability also
+/// requires recoverable stable-storage copies on the quorum.
/// </item>
/// </list>
/// </remarks>
diff --git a/foreign/csharp/Iggy_SDK/Contracts/TopicOptions.cs b/foreign/csharp/Iggy_SDK/Contracts/TopicOptions.cs
index db3572d..a660dcc 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/TopicOptions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/TopicOptions.cs
@@ -16,6 +16,7 @@
// under the License.
using Apache.Iggy.Headers;
+using Apache.Iggy.Enums;
using Apache.Iggy.IggyClient;
namespace Apache.Iggy.Contracts;
@@ -35,7 +36,8 @@
public sealed class TopicOptions
{
private const string SegmentSizeKey = "segment_size";
- private const string EnforceFsyncKey = "enforce_fsync";
+ private const string DurabilityKey = "durability";
+ private const string ConsumerOffsetDurabilityKey = "consumer_offset_durability";
private const string MessagesRequiredToSaveKey = "messages_required_to_save";
private const string SizeOfMessagesRequiredToSaveKey = "size_of_messages_required_to_save";
private const string PreallocateSegmentsKey = "preallocate_segments";
@@ -47,9 +49,12 @@
public ulong? SegmentSize { get; init; }
/// <summary>
- /// Whether writes to this topic's partitions are fsynced.
+ /// Message completion policy. Defaults to replicated independently of offset durability.
/// </summary>
- public bool? EnforceFsync { get; init; }
+ public Durability Durability { get; init; } = Durability.Replicated;
+
+ /// <summary>Explicit offset completion policy. Defaults to replicated independently of message durability.</summary>
+ public Durability ConsumerOffsetDurability { get; init; } = Durability.Replicated;
/// <summary>
/// Flush the journal once it holds this many messages. Must be non-zero.
@@ -71,7 +76,7 @@
/// <summary>
/// Renders the options that were set, each under the kind the server's catalog gives its key.
/// </summary>
- /// <returns>Option values keyed by option name, empty when nothing was set.</returns>
+ /// <returns>Option values keyed by option name, including both durability defaults.</returns>
public Dictionary<string, HeaderValue> ToDictionary()
{
var options = new Dictionary<string, HeaderValue>();
@@ -81,10 +86,8 @@
options[SegmentSizeKey] = HeaderValue.FromUInt64(segmentSize);
}
- if (EnforceFsync is { } enforceFsync)
- {
- options[EnforceFsyncKey] = HeaderValue.FromBool(enforceFsync);
- }
+ options[DurabilityKey] = HeaderValue.FromString(EncodeDurability(Durability));
+ options[ConsumerOffsetDurabilityKey] = HeaderValue.FromString(EncodeDurability(ConsumerOffsetDurability));
if (MessagesRequiredToSave is { } messagesRequiredToSave)
{
@@ -103,4 +106,27 @@
return options;
}
+ internal static Dictionary<string, HeaderValue> WithDurabilityDefaults(IReadOnlyDictionary<string, HeaderValue>? source)
+ {
+ var options = source is null ? new Dictionary<string, HeaderValue>() : new Dictionary<string, HeaderValue>(source);
+ foreach (var key in new[] { DurabilityKey, ConsumerOffsetDurabilityKey })
+ {
+ if (!options.TryGetValue(key, out var value))
+ {
+ options[key] = HeaderValue.FromString("replicated");
+ }
+ else if (value.Kind != HeaderKind.String || value.ToString() is not ("replicated" or "persisted"))
+ {
+ throw new ArgumentException($"Invalid {key}", nameof(source));
+ }
+ }
+ return options;
+ }
+
+ private static string EncodeDurability(Durability value) => value switch
+ {
+ Durability.Replicated => "replicated",
+ Durability.Persisted => "persisted",
+ _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown durability")
+ };
}
diff --git a/foreign/csharp/Iggy_SDK/Enums/Durability.cs b/foreign/csharp/Iggy_SDK/Enums/Durability.cs
new file mode 100644
index 0000000..f491ad3
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Enums/Durability.cs
@@ -0,0 +1,27 @@
+// 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.
+
+namespace Apache.Iggy.Enums;
+
+/// <summary>Storage guarantee required when an operation completes.</summary>
+public enum Durability
+{
+ /// <summary>Quorum commit without waiting for stable storage. Normal persistence continues.</summary>
+ Replicated,
+ /// <summary>Quorum commit backed by recoverable stable-storage copies.</summary>
+ Persisted
+}
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 61fe4ce..85f247a 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -161,6 +161,8 @@
TimeSpan? messageExpiry = null, ulong maxTopicSize = 0,
IReadOnlyDictionary<string, HeaderValue>? options = null, CancellationToken token = default)
{
+
+ options = TopicOptions.WithDurabilityDefaults(options);
var json = JsonSerializer.Serialize(new CreateTopicRequest
{
Name = name,
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index c8e0163..3bd7c87 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -261,6 +261,8 @@
TimeSpan? messageExpiry = null, ulong maxTopicSize = 0,
IReadOnlyDictionary<string, HeaderValue>? options = null, CancellationToken token = default)
{
+
+ options = TopicOptions.WithDurabilityDefaults(options);
var messageExpiryValue = DurationHelpers.ToDuration(messageExpiry);
var message = TcpContracts.CreateTopic(streamId, name, partitionsCount, compressionAlgorithm,
messageExpiryValue, maxTopicSize, options);
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index 29c45d0..4a1851c 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -26,7 +26,7 @@
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<AssemblyName>Apache.Iggy</AssemblyName>
<RootNamespace>Apache.Iggy</RootNamespace>
- <Version>0.9.0-edge.8</Version>
+ <Version>0.9.0-edge.9</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpTopicOptionsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpTopicOptionsTests.cs
index 9b161b0..3773953 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpTopicOptionsTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpTopicOptionsTests.cs
@@ -38,7 +38,7 @@
"partitions_count": 1,
"partitions": [],
"options": {
- "enforce_fsync": { "value": "true", "explicit": true },
+ "preallocate_segments": { "value": "true", "explicit": true },
"segment_size": { "value": "134217728", "explicit": false }
}
}
@@ -63,10 +63,10 @@
"description": "Segment size in bytes"
},
{
- "key": "enforce_fsync",
+ "key": "preallocate_segments",
"kind": "bool",
"default_value": [0],
- "description": "Whether writes to this topic's partitions fsync"
+ "description": "Message completion policy: replicated or persisted"
}
]
""";
@@ -84,7 +84,7 @@
Assert.NotNull(topic);
var explicitOption = Assert.Single(topic.Options!);
- Assert.Equal("enforce_fsync", explicitOption.Key.AsString());
+ Assert.Equal("preallocate_segments", explicitOption.Key.AsString());
Assert.Equal("true", explicitOption.Value.ToString());
var derivedOption = Assert.Single(topic.DerivedOptions!);
@@ -93,16 +93,17 @@
}
[Fact]
- public async Task CreateTopic_SendsABoolOptionAsTheWordTheServerParses()
+ public async Task CreateTopic_SendsDurabilityAndIndependentOffsetDefault()
{
var handler = new StubHandler(TopicResponseJson);
var client = new HttpMessageStream(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") });
await client.CreateTopicAsync(StreamId, "topic", 1,
- options: new TopicOptions { EnforceFsync = true, SegmentSize = 134217728 }.ToDictionary(),
+ options: new TopicOptions { Durability = Apache.Iggy.Enums.Durability.Persisted, SegmentSize = 134217728 }.ToDictionary(),
token: TestContext.Current.CancellationToken);
- Assert.Contains("\"enforce_fsync\":\"true\"", handler.RequestBody);
+ Assert.Contains("\"durability\":\"persisted\"", handler.RequestBody);
+ Assert.Contains("\"consumer_offset_durability\":\"replicated\"", handler.RequestBody);
Assert.Contains("\"segment_size\":\"134217728\"", handler.RequestBody);
}
@@ -138,7 +139,7 @@
Assert.Equal(HeaderKind.Uint64, specs[1].Kind);
Assert.Equal(1073741824UL, BitConverter.ToUInt64(specs[1].DefaultValue));
- Assert.Equal("enforce_fsync", specs[2].Key);
+ Assert.Equal("preallocate_segments", specs[2].Key);
Assert.Equal(HeaderKind.Bool, specs[2].Kind);
Assert.Equal([0], specs[2].DefaultValue);
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
index b35afe3..c2519cf 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
@@ -278,7 +278,7 @@
var options = new List<byte>();
options.AddRange(BinaryFactory.CreateOptionEntry(stringKind, "segment_size", stringKind, "1GB"u8.ToArray()));
options.AddRange(BinaryFactory.CreateOptionEntry(stringKind, "future_option", unknownKind, [0xAA, 0xBB]));
- options.AddRange(BinaryFactory.CreateOptionEntry(stringKind, "enforce_fsync", 3, [1]));
+ options.AddRange(BinaryFactory.CreateOptionEntry(stringKind, "preallocate_segments", 3, [1]));
var topicPayload = BinaryFactory.CreateTopicPayload(topicId, partitionsCount, messageExpiry, topicName,
sizeBytes, messagesCount, createdAt, maxTopicSize, 1, options.ToArray());
@@ -290,7 +290,7 @@
Assert.NotNull(response.Options);
Assert.Equal(2, response.Options.Count);
Assert.Equal("1GB", response.Options[HeaderKey.FromString("segment_size")].ToString());
- Assert.Equal(HeaderKind.Bool, response.Options[HeaderKey.FromString("enforce_fsync")].Kind);
+ Assert.Equal(HeaderKind.Bool, response.Options[HeaderKey.FromString("preallocate_segments")].Kind);
Assert.False(response.Options.ContainsKey(HeaderKey.FromString("future_option")));
Assert.NotNull(response.DerivedOptions);
Assert.Empty(response.DerivedOptions);
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
index fe4ed73..6e41cb2 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
@@ -31,24 +31,22 @@
/// about interoperability; these bytes are the contract, and a change to the TLV layout has to
/// break every copy of them together.
///
-/// enforce_fsync (a one-byte Bool) and segment_size (an eight-byte Uint64) cover both value
-/// widths. What the vector pins is the per-entry byte layout, not a key order: these two land
-/// sorted only because the Rust core holds options in a BTreeMap, and the server accepts the
-/// insertion order this SDK emits.
+/// The vector covers Bool, Uint64 and String values in insertion order. It deliberately
+/// differs from Rust's sorted map order. Decoders accept either order.
/// </summary>
public sealed class OptionsBlockGoldenVectorTests
{
private static readonly byte[] GoldenOptionsBlock =
[
- 2, 13, 0, 0, 0,
- (byte)'e', (byte)'n', (byte)'f', (byte)'o', (byte)'r', (byte)'c', (byte)'e', (byte)'_', (byte)'f', (byte)'s',
- (byte)'y', (byte)'n', (byte)'c',
+ 2, 20, 0, 0, 0,
+ (byte)'p', (byte)'r', (byte)'e', (byte)'a', (byte)'l', (byte)'l', (byte)'o', (byte)'c', (byte)'a', (byte)'t', (byte)'e', (byte)'_', (byte)'s', (byte)'e', (byte)'g', (byte)'m', (byte)'e', (byte)'n', (byte)'t', (byte)'s',
3, 1, 0, 0, 0, 1,
2, 12, 0, 0, 0,
(byte)'s', (byte)'e', (byte)'g', (byte)'m', (byte)'e', (byte)'n', (byte)'t', (byte)'_', (byte)'s', (byte)'i',
(byte)'z', (byte)'e',
12, 8, 0, 0, 0,
- 0, 0, 0, 64, 0, 0, 0, 0
+ 0, 0, 0, 64, 0, 0, 0, 0,
+ 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101, 114, 115, 105, 115, 116, 101, 100
];
[Fact]
@@ -56,8 +54,9 @@
{
var options = new Dictionary<HeaderKey, HeaderValue>
{
- [HeaderKey.FromString("enforce_fsync")] = HeaderValue.FromBool(true),
- [HeaderKey.FromString("segment_size")] = HeaderValue.FromUInt64(1_073_741_824)
+ [HeaderKey.FromString("preallocate_segments")] = HeaderValue.FromBool(true),
+ [HeaderKey.FromString("segment_size")] = HeaderValue.FromUInt64(1_073_741_824),
+ [HeaderKey.FromString("durability")] = HeaderValue.FromString("persisted")
};
var encoded = new byte[TcpContracts.HeadersByteLength(options)];
@@ -72,10 +71,11 @@
var topic = Mappers.BinaryMapper.MapTopic(TopicPayloadWithOptions(GoldenOptionsBlock));
Assert.NotNull(topic.Options);
- Assert.Equal(2, topic.Options.Count);
- Assert.True(topic.Options[HeaderKey.FromString("enforce_fsync")].ToBool());
+ Assert.Equal(3, topic.Options.Count);
+ Assert.Equal("persisted", topic.Options[HeaderKey.FromString("durability")].ToString());
+ Assert.True(topic.Options[HeaderKey.FromString("preallocate_segments")].ToBool());
Assert.Equal(1_073_741_824UL, topic.Options[HeaderKey.FromString("segment_size")].ToUInt64());
- Assert.Equal(HeaderKind.Bool, topic.Options[HeaderKey.FromString("enforce_fsync")].Kind);
+ Assert.Equal(HeaderKind.Bool, topic.Options[HeaderKey.FromString("preallocate_segments")].Kind);
Assert.Equal(HeaderKind.Uint64, topic.Options[HeaderKey.FromString("segment_size")].Kind);
Assert.Empty(topic.DerivedOptions!);
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/ResourceOptionsConverterTests.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/ResourceOptionsConverterTests.cs
index 3c8fb7c..a30af7b 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/ResourceOptionsConverterTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/ResourceOptionsConverterTests.cs
@@ -53,7 +53,7 @@
"partitions_count": 1,
"partitions": [],
"options": {
- "enforce_fsync": { "value": "true", "explicit": true },
+ "preallocate_segments": { "value": "true", "explicit": true },
"segment_size": { "value": "134217728", "explicit": true },
"messages_required_to_save": { "value": "1024", "explicit": false },
"preallocate_segments": { "value": "false", "explicit": false }
@@ -70,7 +70,7 @@
Assert.NotNull(topic.DerivedOptions);
Assert.Equal(2, topic.Options.Count);
- Assert.Equal("true", topic.Options[HeaderKey.FromString("enforce_fsync")].ToString());
+ Assert.Equal("true", topic.Options[HeaderKey.FromString("preallocate_segments")].ToString());
Assert.Equal("134217728", topic.Options[HeaderKey.FromString("segment_size")].ToString());
Assert.Equal(2, topic.DerivedOptions.Count);
@@ -161,7 +161,7 @@
"max_topic_size": 0,
"messages_count": 0,
"partitions_count": 1,
- "options": { "enforce_fsync": { "value": "true", "explicit": true } }
+ "options": { "preallocate_segments": { "value": "true", "explicit": true } }
},
{
"id": 2,
@@ -171,7 +171,7 @@
"max_topic_size": 0,
"messages_count": 0,
"partitions_count": 1,
- "options": { "enforce_fsync": { "value": "false", "explicit": false } }
+ "options": { "preallocate_segments": { "value": "false", "explicit": false } }
}
]
""";
diff --git a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/TopicOptionsTests.cs b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/TopicOptionsTests.cs
index e1595c3..73ae30a 100644
--- a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/TopicOptionsTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/TopicOptionsTests.cs
@@ -28,19 +28,19 @@
var options = new TopicOptions
{
SegmentSize = 134217728,
- EnforceFsync = true,
+ Durability = Apache.Iggy.Enums.Durability.Persisted,
MessagesRequiredToSave = 1024,
SizeOfMessagesRequiredToSave = 1048576,
PreallocateSegments = false
}.ToDictionary();
- Assert.Equal(5, options.Count);
+ Assert.Equal(6, options.Count);
Assert.Equal(HeaderKind.Uint64, options["segment_size"].Kind);
Assert.Equal(134217728UL, options["segment_size"].ToUInt64());
- Assert.Equal(HeaderKind.Bool, options["enforce_fsync"].Kind);
- Assert.True(options["enforce_fsync"].ToBool());
+ Assert.Equal(HeaderKind.String, options["durability"].Kind);
+ Assert.Equal("persisted", options["durability"].ToString());
Assert.Equal(HeaderKind.Uint32, options["messages_required_to_save"].Kind);
Assert.Equal(1024U, options["messages_required_to_save"].ToUInt32());
@@ -55,16 +55,16 @@
[Fact]
public void ToDictionary_EmitsOnlyTheKeysThatWereSet()
{
- var options = new TopicOptions { EnforceFsync = false }.ToDictionary();
+ var options = new TopicOptions { Durability = Apache.Iggy.Enums.Durability.Replicated }.ToDictionary();
- var entry = Assert.Single(options);
- Assert.Equal("enforce_fsync", entry.Key);
- Assert.False(entry.Value.ToBool());
+ Assert.Equal(2, options.Count);
+ Assert.Equal("replicated", options["durability"].ToString());
+ Assert.Equal("replicated", options["consumer_offset_durability"].ToString());
}
[Fact]
- public void ToDictionary_WithNothingSetIsEmpty()
+ public void ToDictionary_EmitsBothReplicatedDefaults()
{
- Assert.Empty(new TopicOptions().ToDictionary());
+ Assert.Equal(2, new TopicOptions().ToDictionary().Count);
}
}
diff --git a/foreign/go/README.md b/foreign/go/README.md
index 321f0de..ea6a81d 100644
--- a/foreign/go/README.md
+++ b/foreign/go/README.md
@@ -26,7 +26,7 @@
```bash
cargo build --bin iggy-server
-IGGY_SYSTEM_PATH=/tmp/iggy-go \
+IGGY_PATH=/tmp/iggy-go \
IGGY_TCP_ADDRESS=127.0.0.1:8090 \
IGGY_HTTP_ENABLED=false IGGY_QUIC_ENABLED=false IGGY_WEBSOCKET_ENABLED=false \
IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
diff --git a/foreign/go/contracts/compression_algorithm_test.go b/foreign/go/contracts/compression_algorithm_test.go
index 693fb0f..46bdc69 100644
--- a/foreign/go/contracts/compression_algorithm_test.go
+++ b/foreign/go/contracts/compression_algorithm_test.go
@@ -57,15 +57,17 @@
//
// Rust pins the identical bytes in core/binary_protocol/src/primitives/options.rs,
// as do the Node and Java SDKs. Round-tripping through this SDK's own decoder
-// proves nothing about interoperability; these bytes are the contract.
+// proves nothing about interoperability. This insertion-order vector covers Bool,
+// Uint64 and String values, independently of Rust's sorted map order.
var goldenOptionsBlock = []byte{
- 2, 13, 0, 0, 0,
- 'e', 'n', 'f', 'o', 'r', 'c', 'e', '_', 'f', 's', 'y', 'n', 'c',
+ 2, 20, 0, 0, 0,
+ 'p', 'r', 'e', 'a', 'l', 'l', 'o', 'c', 'a', 't', 'e', '_', 's', 'e', 'g', 'm', 'e', 'n', 't', 's',
3, 1, 0, 0, 0, 1,
2, 12, 0, 0, 0,
's', 'e', 'g', 'm', 'e', 'n', 't', '_', 's', 'i', 'z', 'e',
12, 8, 0, 0, 0,
0, 0, 0, 64, 0, 0, 0, 0,
+ 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101, 114, 115, 105, 115, 116, 101, 100,
}
func TestGetHeadersBytes_MatchesTheCrossSdkGoldenVector(t *testing.T) {
@@ -73,7 +75,7 @@
binary.LittleEndian.PutUint64(segmentSize, 1073741824)
entries := []HeaderEntry{
{
- Key: HeaderKey{Kind: String, Value: []byte("enforce_fsync")},
+ Key: HeaderKey{Kind: String, Value: []byte("preallocate_segments")},
Value: HeaderValue{Kind: Bool, Value: []byte{1}},
},
{
@@ -82,6 +84,10 @@
},
}
+ entries = append(entries, HeaderEntry{
+ Key: HeaderKey{Kind: String, Value: []byte("durability")},
+ Value: HeaderValue{Kind: String, Value: []byte("persisted")},
+ })
got := GetHeadersBytes(entries)
if !bytes.Equal(got, goldenOptionsBlock) {
diff --git a/foreign/go/contracts/topic_options.go b/foreign/go/contracts/topic_options.go
index 7199fbf..7f1069b 100644
--- a/foreign/go/contracts/topic_options.go
+++ b/foreign/go/contracts/topic_options.go
@@ -29,7 +29,8 @@
// UpdateTopic refuses it by name.
const (
topicOptionSegmentSize = "segment_size"
- topicOptionEnforceFsync = "enforce_fsync"
+ topicOptionDurability = "durability"
+ topicOptionConsumerOffsetDurability = "consumer_offset_durability"
topicOptionMessagesRequiredToSave = "messages_required_to_save"
topicOptionSizeOfMessagesRequiredToSave = "size_of_messages_required_to_save"
topicOptionPreallocateSegments = "preallocate_segments"
@@ -42,9 +43,19 @@
return uint64Option(topicOptionSegmentSize, bytes)
}
-// EnforceFsyncOption makes writes to the topic's partitions fsync.
-func EnforceFsyncOption(enabled bool) HeaderEntry {
- return boolOption(topicOptionEnforceFsync, enabled)
+type Durability string
+
+const (
+ DurabilityReplicated Durability = "replicated"
+ DurabilityPersisted Durability = "persisted"
+)
+
+func DurabilityOption(value Durability) HeaderEntry {
+ return HeaderEntry{Key: HeaderKey{Kind: String, Value: []byte(topicOptionDurability)}, Value: HeaderValue{Kind: String, Value: []byte(value)}}
+}
+
+func ConsumerOffsetDurabilityOption(value Durability) HeaderEntry {
+ return HeaderEntry{Key: HeaderKey{Kind: String, Value: []byte(topicOptionConsumerOffsetDurability)}, Value: HeaderValue{Kind: String, Value: []byte(value)}}
}
// MessagesRequiredToSaveOption flushes the journal once it holds this many
diff --git a/foreign/go/contracts/topic_options_test.go b/foreign/go/contracts/topic_options_test.go
index 885812e..c812184 100644
--- a/foreign/go/contracts/topic_options_test.go
+++ b/foreign/go/contracts/topic_options_test.go
@@ -42,18 +42,18 @@
wantValue: []byte{0, 0, 0, 64, 0, 0, 0, 0},
},
{
- name: "enforce fsync on",
- entry: EnforceFsyncOption(true),
- wantKey: "enforce_fsync",
- wantKind: Bool,
- wantValue: []byte{1},
+ name: "persisted message durability",
+ entry: DurabilityOption(DurabilityPersisted),
+ wantKey: "durability",
+ wantKind: String,
+ wantValue: []byte("persisted"),
},
{
- name: "enforce fsync off",
- entry: EnforceFsyncOption(false),
- wantKey: "enforce_fsync",
- wantKind: Bool,
- wantValue: []byte{0},
+ name: "replicated message durability",
+ entry: DurabilityOption(DurabilityReplicated),
+ wantKey: "durability",
+ wantKind: String,
+ wantValue: []byte("replicated"),
},
{
name: "messages required to save",
@@ -99,7 +99,7 @@
if !bytes.Equal(test.entry.Value.Value, test.wantValue) {
t.Errorf("value = %v, want %v", test.entry.Value.Value, test.wantValue)
}
- if expected := test.wantKind.ExpectedSize(); len(test.entry.Value.Value) != expected {
+ if expected := test.wantKind.ExpectedSize(); test.wantKind != String && len(test.entry.Value.Value) != expected {
t.Errorf("value length = %d, want %d for kind %d",
len(test.entry.Value.Value), expected, test.wantKind)
}
@@ -110,7 +110,7 @@
func TestTopicOptions_ConstructorEntriesSurviveTheHeaderCodec(t *testing.T) {
entries := []HeaderEntry{
SegmentSizeOption(1 << 20),
- EnforceFsyncOption(true),
+ DurabilityOption(DurabilityPersisted),
MessagesRequiredToSaveOption(7),
SizeOfMessagesRequiredToSaveOption(4096),
PreallocateSegmentsOption(false),
diff --git a/foreign/go/contracts/version.go b/foreign/go/contracts/version.go
index f379908..6069f3b 100644
--- a/foreign/go/contracts/version.go
+++ b/foreign/go/contracts/version.go
@@ -17,4 +17,4 @@
package iggcon
-const Version = "0.9.0-edge.6"
+const Version = "0.9.0-edge.7"
diff --git a/foreign/go/internal/command/topic.go b/foreign/go/internal/command/topic.go
index 9add5dd..8455ef4 100644
--- a/foreign/go/internal/command/topic.go
+++ b/foreign/go/internal/command/topic.go
@@ -88,7 +88,25 @@
if t.MaxTopicSize != 0 {
options = append(options, uint64Option(topicOptionMaxTopicSize, t.MaxTopicSize))
}
- return mergeOptions(options, t.Options)
+ merged, err := mergeOptions(options, t.Options)
+ if err != nil {
+ return nil, err
+ }
+ for _, key := range []string{"durability", "consumer_offset_durability"} {
+ found := false
+ for _, entry := range merged {
+ if string(entry.Key.Value) == key {
+ found = true
+ if entry.Value.Kind != iggcon.String || (string(entry.Value.Value) != string(iggcon.DurabilityReplicated) && string(entry.Value.Value) != string(iggcon.DurabilityPersisted)) {
+ return nil, fmt.Errorf("invalid %s", key)
+ }
+ }
+ }
+ if !found {
+ merged = append(merged, stringOption(key, string(iggcon.DurabilityReplicated)))
+ }
+ }
+ return merged, nil
}
// mergeOptions appends the caller's entries to the ones the typed fields
diff --git a/foreign/go/internal/command/topic_test.go b/foreign/go/internal/command/topic_test.go
index 2334086..2c450a0 100644
--- a/foreign/go/internal/command/topic_test.go
+++ b/foreign/go/internal/command/topic_test.go
@@ -46,7 +46,10 @@
0x02, 0x00, 0x00, 0x00, // PartitionsCount (2)
0x05, // Name Length (5)
0x74, 0x6F, 0x70, 0x69, 0x63, // Name ("topic")
- // options: empty, every default is derived server-side
+ 2, 10, 0, 0, 0, 'd', 'u', 'r', 'a', 'b', 'i', 'l', 'i', 't', 'y',
+ 2, 10, 0, 0, 0, 'r', 'e', 'p', 'l', 'i', 'c', 'a', 't', 'e', 'd',
+ 2, 26, 0, 0, 0, 'c', 'o', 'n', 's', 'u', 'm', 'e', 'r', '_', 'o', 'f', 'f', 's', 'e', 't', '_', 'd', 'u', 'r', 'a', 'b', 'i', 'l', 'i', 't', 'y',
+ 2, 10, 0, 0, 0, 'r', 'e', 'p', 'l', 'i', 'c', 'a', 't', 'e', 'd',
}
if !bytes.Equal(serialized, expected) {
@@ -90,8 +93,8 @@
byKey[string(entry.Key.Value)] = entry.Value
}
- if len(byKey) != 3 {
- t.Fatalf("expected 3 options, got %d: %v", len(byKey), byKey)
+ if len(byKey) != 5 {
+ t.Fatalf("expected 5 options, got %d: %v", len(byKey), byKey)
}
if _, found := byKey["partitions_count"]; found {
t.Error("partitions_count rides the fixed field, not the options block")
@@ -163,8 +166,8 @@
MaxTopicSize: 4096,
Options: []iggcon.HeaderEntry{
{
- Key: iggcon.HeaderKey{Kind: iggcon.String, Value: []byte("enforce_fsync")},
- Value: iggcon.HeaderValue{Kind: iggcon.Bool, Value: []byte{1}},
+ Key: iggcon.HeaderKey{Kind: iggcon.String, Value: []byte("durability")},
+ Value: iggcon.HeaderValue{Kind: iggcon.String, Value: []byte("persisted")},
},
// The typed field already covers this key, so the caller's entry is
// dropped: a duplicate key makes the server refuse the whole block.
@@ -188,7 +191,7 @@
}
byKey[key] = entry.Value
}
- if _, ok := byKey["enforce_fsync"]; !ok {
+ if _, ok := byKey["durability"]; !ok {
t.Error("a caller-supplied key must reach the options block")
}
if got := byKey["max_topic_size"]; got.Kind != iggcon.Uint64 {
@@ -207,7 +210,7 @@
PartitionsCount: 1,
Options: []iggcon.HeaderEntry{
iggcon.SegmentSizeOption(1 << 20),
- iggcon.EnforceFsyncOption(true),
+ iggcon.DurabilityOption(iggcon.DurabilityPersisted),
iggcon.MessagesRequiredToSaveOption(7),
iggcon.SizeOfMessagesRequiredToSaveOption(4096),
iggcon.PreallocateSegmentsOption(false),
@@ -238,7 +241,8 @@
value []byte
}{
{"segment_size", iggcon.Uint64, []byte{0, 0, 16, 0, 0, 0, 0, 0}},
- {"enforce_fsync", iggcon.Bool, []byte{1}},
+ {"durability", iggcon.String, []byte("persisted")},
+ {"consumer_offset_durability", iggcon.String, []byte("replicated")},
{"messages_required_to_save", iggcon.Uint32, []byte{7, 0, 0, 0}},
{"size_of_messages_required_to_save", iggcon.Uint64, []byte{0, 16, 0, 0, 0, 0, 0, 0}},
{"preallocate_segments", iggcon.Bool, []byte{0}},
diff --git a/foreign/go/tests/e2e_helpers_test.go b/foreign/go/tests/e2e_helpers_test.go
index b7d7bf6..b87db72 100644
--- a/foreign/go/tests/e2e_helpers_test.go
+++ b/foreign/go/tests/e2e_helpers_test.go
@@ -20,7 +20,7 @@
// The suite skips unless IGGY_TCP_ADDRESS points at a server. Start one with:
//
// cargo build --bin iggy-server
-// IGGY_SYSTEM_PATH=/tmp/iggy-go-e2e \
+// IGGY_PATH=/tmp/iggy-go-e2e \
// IGGY_TCP_ADDRESS=127.0.0.1:8090 \
// IGGY_HTTP_ENABLED=false IGGY_QUIC_ENABLED=false IGGY_WEBSOCKET_ENABLED=false \
// IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
diff --git a/foreign/go/tests/e2e_test.go b/foreign/go/tests/e2e_test.go
index c6ee7d6..0c151e1 100644
--- a/foreign/go/tests/e2e_test.go
+++ b/foreign/go/tests/e2e_test.go
@@ -322,7 +322,7 @@
for _, spec := range specs {
byKey[spec.Key] = spec
}
- require.Contains(t, byKey, "enforce_fsync", "the catalog lists the keys create accepts")
+ require.Contains(t, byKey, "durability", "the catalog lists the keys create accepts")
require.Contains(t, byKey, "segment_size")
assert.NotEmpty(t, byKey["segment_size"].Description)
assert.Equal(t, iggcon.Uint64, byKey["segment_size"].DefaultValue.Kind)
@@ -343,8 +343,8 @@
created, err := connected.CreateTopic(ctx, streamId, name, 1,
iggcon.CompressionAlgorithmNone, iggcon.Duration(0), 0,
iggcon.HeaderEntry{
- Key: iggcon.HeaderKey{Kind: iggcon.String, Value: []byte("enforce_fsync")},
- Value: iggcon.HeaderValue{Kind: iggcon.String, Value: []byte("true")},
+ Key: iggcon.HeaderKey{Kind: iggcon.String, Value: []byte("durability")},
+ Value: iggcon.HeaderValue{Kind: iggcon.String, Value: []byte("persisted")},
})
require.NoError(t, err)
@@ -353,16 +353,16 @@
topic, err := connected.GetTopic(ctx, streamId, topicId)
require.NoError(t, err)
- fsync, ok := topic.Options["enforce_fsync"]
+ durability, ok := topic.Options["durability"]
require.True(t, ok, "an explicitly set key is reported as explicit, got %v", topic.Options)
// Create admission re-encodes the block from its own parse, so the stored
// value carries the key's canonical kind whatever kind the client sent it
- // as: this string "true" comes back as a Bool.
- assert.Equal(t, iggcon.Bool, fsync.Kind)
- assert.Equal(t, []byte{1}, fsync.Value)
+ // as: durability is a canonical String token.
+ assert.Equal(t, iggcon.String, durability.Kind)
+ assert.Equal(t, []byte("persisted"), durability.Value)
// Keys the client left alone are resolved by admission and reported apart.
require.Contains(t, topic.DerivedOptions, "max_topic_size")
- assert.NotContains(t, topic.DerivedOptions, "enforce_fsync")
+ assert.NotContains(t, topic.DerivedOptions, "durability")
// A key outside the catalog is refused by name.
_, err = connected.CreateTopic(ctx, streamId, name+"-bad", 1,
@@ -383,7 +383,7 @@
// nothing is reserved on disk.
typed := []iggcon.HeaderEntry{
iggcon.SegmentSizeOption(1024 * 1024),
- iggcon.EnforceFsyncOption(true),
+ iggcon.DurabilityOption(iggcon.DurabilityPersisted),
iggcon.MessagesRequiredToSaveOption(7),
iggcon.SizeOfMessagesRequiredToSaveOption(4096),
iggcon.PreallocateSegmentsOption(false),
diff --git a/foreign/java/external-processors/iggy-connector-flink/docker-compose.yml b/foreign/java/external-processors/iggy-connector-flink/docker-compose.yml
index 8b20600..ae7ec1d 100644
--- a/foreign/java/external-processors/iggy-connector-flink/docker-compose.yml
+++ b/foreign/java/external-processors/iggy-connector-flink/docker-compose.yml
@@ -29,7 +29,7 @@
- IGGY_NODE_ADVERTISED_ADDRESS=iggy
- IGGY_HTTP_ADDRESS=0.0.0.0:3000
- IGGY_QUIC_ADDRESS=0.0.0.0:8080
- - IGGY_SYSTEM_LOGGING_LEVEL=info
+ - IGGY_LOGGING_LEVEL=info
- IGGY_ROOT_USERNAME=iggy
- IGGY_ROOT_PASSWORD=iggy
volumes:
diff --git a/foreign/java/external-processors/iggy-connector-pinot/docker-compose.yml b/foreign/java/external-processors/iggy-connector-pinot/docker-compose.yml
index 49fed9a..c7cb3df 100644
--- a/foreign/java/external-processors/iggy-connector-pinot/docker-compose.yml
+++ b/foreign/java/external-processors/iggy-connector-pinot/docker-compose.yml
@@ -25,7 +25,7 @@
- "3000:3000" # HTTP
- "8080:8080" # QUIC
environment:
- - IGGY_SYSTEM_LOGGING_LEVEL=info
+ - IGGY_LOGGING_LEVEL=info
- IGGY_TCP_ADDRESS=0.0.0.0:8090
- IGGY_NODE_ADVERTISED_ADDRESS=iggy
- IGGY_HTTP_ENABLED=true
diff --git a/foreign/java/external-processors/iggy-connector-pinot/src/test/java/org/apache/iggy/connector/pinot/IggyPinotIntegrationTest.java b/foreign/java/external-processors/iggy-connector-pinot/src/test/java/org/apache/iggy/connector/pinot/IggyPinotIntegrationTest.java
index af67441..a65dc36 100644
--- a/foreign/java/external-processors/iggy-connector-pinot/src/test/java/org/apache/iggy/connector/pinot/IggyPinotIntegrationTest.java
+++ b/foreign/java/external-processors/iggy-connector-pinot/src/test/java/org/apache/iggy/connector/pinot/IggyPinotIntegrationTest.java
@@ -228,7 +228,7 @@
.withEnv("IGGY_TCP_ADDRESS", "0.0.0.0:" + IGGY_TCP_PORT)
.withEnv("IGGY_HTTP_ADDRESS", "0.0.0.0:" + IGGY_HTTP_PORT)
.withEnv("IGGY_NODE_ADVERTISED_ADDRESS", IGGY_NETWORK_ALIAS)
- .withEnv("IGGY_SYSTEM_SHARDING_CPU_ALLOCATION", "all")
+ .withEnv("IGGY_SHARDING_CPU_ALLOCATION", "all")
.withCreateContainerCmdModifier(cmd -> cmd.getHostConfig()
.withCapAdd(Capability.SYS_NICE)
.withSecurityOpts(List.of("seccomp:unconfined"))
diff --git a/foreign/java/gradle.properties b/foreign/java/gradle.properties
index 5747b90..d6e8e6b 100644
--- a/foreign/java/gradle.properties
+++ b/foreign/java/gradle.properties
@@ -15,5 +15,5 @@
# specific language governing permissions and limitations
# under the License.
-version=0.9.0-SNAPSHOT
+version=0.9.1-SNAPSHOT
group=org.apache.iggy
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TopicsTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TopicsTcpClient.java
index a25371a..4235600 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TopicsTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TopicsTcpClient.java
@@ -110,7 +110,13 @@
Map<String, HeaderValue> options) {
var payload = createTopicPayload(
- streamId, partitionsCount, compressionAlgorithm, messageExpiry, maxTopicSize, name, options);
+ streamId,
+ partitionsCount,
+ compressionAlgorithm,
+ messageExpiry,
+ maxTopicSize,
+ name,
+ org.apache.iggy.topic.TopicOptions.withDurabilityDefaults(options));
return connection().send(CommandCode.Topic.CREATE.getValue(), payload).thenApply(response -> {
try {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/TopicsHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/TopicsHttpClient.java
index 6c1eec9..3e4255a 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/TopicsHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/TopicsHttpClient.java
@@ -77,7 +77,7 @@
messageExpiry,
maxTopicSize,
name,
- toStringOptions(options)));
+ toStringOptions(org.apache.iggy.topic.TopicOptions.withDurabilityDefaults(options))));
return httpClient.execute(request, HttpTopicDetails.class).toTopicDetails();
}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/Durability.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/Durability.java
new file mode 100644
index 0000000..2c667af
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/Durability.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.topic;
+
+public enum Durability {
+ REPLICATED("replicated"),
+ PERSISTED("persisted");
+
+ private final String value;
+
+ Durability(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/TopicOptions.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/TopicOptions.java
index 38689c8..d134e2b 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/TopicOptions.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/topic/TopicOptions.java
@@ -40,7 +40,7 @@
* <pre>{@code
* var options = TopicOptions.builder()
* .segmentSize(BigInteger.valueOf(134_217_728))
- * .enforceFsync(true)
+ * .durability(Durability.PERSISTED)
* .build();
* topicsClient.createTopic(streamId, 1L, CompressionAlgorithm.None,
* BigInteger.ZERO, BigInteger.ZERO, "orders", options);
@@ -50,6 +50,25 @@
private TopicOptions() {}
+ public static Map<String, HeaderValue> withDurabilityDefaults(Map<String, HeaderValue> source) {
+ Map<String, HeaderValue> resolved = new LinkedHashMap<>();
+ if (source != null) {
+ resolved.putAll(source);
+ }
+ for (String key : new String[] {"durability", "consumer_offset_durability"}) {
+ var value = resolved.get(key);
+ if (!resolved.containsKey(key)) {
+ resolved.put(key, HeaderValue.fromString(Durability.REPLICATED.value()));
+ } else if (value == null
+ || value.kind() != org.apache.iggy.message.HeaderKind.String
+ || !(value.toStringValue().equals("replicated")
+ || value.toStringValue().equals("persisted"))) {
+ throw new IllegalArgumentException("Invalid " + key);
+ }
+ }
+ return resolved;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -58,7 +77,10 @@
private final Map<String, HeaderValue> options = new LinkedHashMap<>();
- private Builder() {}
+ private Builder() {
+ durability(Durability.REPLICATED);
+ consumerOffsetDurability(Durability.REPLICATED);
+ }
/** Per-topic segment size in bytes: a 512-byte multiple within the server's bounds. */
public Builder segmentSize(BigInteger bytes) {
@@ -66,9 +88,14 @@
return this;
}
- /** Whether writes to this topic's partitions fsync. */
- public Builder enforceFsync(boolean enabled) {
- options.put("enforce_fsync", HeaderValue.fromBool(enabled));
+ /** Message completion policy, independently defaulting to replicated. */
+ public Builder durability(Durability durability) {
+ options.put("durability", HeaderValue.fromString(durability.value()));
+ return this;
+ }
+
+ public Builder consumerOffsetDurability(Durability durability) {
+ options.put("consumer_offset_durability", HeaderValue.fromString(durability.value()));
return this;
}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/BaseIntegrationTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/BaseIntegrationTest.java
index c25345a..acec043 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/BaseIntegrationTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/BaseIntegrationTest.java
@@ -88,7 +88,7 @@
.withEnv("IGGY_TCP_ADDRESS", "0.0.0.0:" + TCP_PORT)
.withEnv("IGGY_HTTP_ADDRESS", "0.0.0.0:" + HTTP_PORT)
.withEnv("IGGY_NODE_ADVERTISED_ADDRESS", LOCALHOST_IP)
- .withEnv("IGGY_SYSTEM_SHARDING_CPU_ALLOCATION", "all")
+ .withEnv("IGGY_SHARDING_CPU_ALLOCATION", "all")
.withCreateContainerCmdModifier(cmd -> cmd.getHostConfig()
.withCapAdd(Capability.SYS_NICE)
.withSecurityOpts(List.of("seccomp:unconfined"))
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
index 5330397..a261172 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
@@ -49,7 +49,7 @@
// then
var byKey = specs.stream().collect(Collectors.toMap(OptionSpec::key, spec -> spec));
- assertThat(byKey).containsKeys("segment_size", "enforce_fsync");
+ assertThat(byKey).containsKeys("segment_size", "durability");
var segmentSize = byKey.get("segment_size");
assertThat(segmentSize.defaultValue().kind()).isEqualTo(HeaderKind.Uint64);
assertThat(segmentSize.defaultValue().value()).isNotEmpty();
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/TopicsClientBaseTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/TopicsClientBaseTest.java
index 9e6657e..48e64d2 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/TopicsClientBaseTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/TopicsClientBaseTest.java
@@ -96,18 +96,16 @@
BigInteger.ZERO,
BigInteger.ZERO,
"options-topic",
- Map.of("enforce_fsync", HeaderValue.fromString("true")));
+ Map.of("durability", HeaderValue.fromString("persisted")));
// then
var topic = topicsClient.getTopic(STREAM_NAME, TopicId.of(created.id())).orElseThrow();
- // Asserted through the string rendering rather than the kind: the binary
- // transport reports the key's canonical Bool while REST renders values as
- // strings, and both mean the same setting.
- assertThat(topic.options()).containsKey("enforce_fsync");
- assertThat(topic.options().get("enforce_fsync").toStringValue()).isEqualTo("true");
+ // Both transports preserve the canonical durability token.
+ assertThat(topic.options()).containsKey("durability");
+ assertThat(topic.options().get("durability").toStringValue()).isEqualTo("persisted");
// Keys the client left alone are resolved by admission and reported apart.
assertThat(topic.derivedOptions()).containsKey("max_topic_size");
- assertThat(topic.derivedOptions()).doesNotContainKey("enforce_fsync");
+ assertThat(topic.derivedOptions()).doesNotContainKey("durability");
}
@Test
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TopicsTcpClientTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TopicsTcpClientTest.java
index cce9775..d76e35c 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TopicsTcpClientTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TopicsTcpClientTest.java
@@ -53,10 +53,10 @@
BigInteger.ZERO,
BigInteger.ZERO,
"canonical-kind-topic",
- Map.of("enforce_fsync", HeaderValue.fromString("true")));
+ Map.of("durability", HeaderValue.fromString("persisted")));
var topic = topicsClient.getTopic(STREAM_NAME, TopicId.of(created.id())).orElseThrow();
- assertThat(topic.options().get("enforce_fsync").kind()).isEqualTo(HeaderKind.Bool);
+ assertThat(topic.options().get("durability").kind()).isEqualTo(HeaderKind.String);
}
@Test
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/OptionsBlockGoldenVectorTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/OptionsBlockGoldenVectorTest.java
index b12ecc3..5397f17 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/OptionsBlockGoldenVectorTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/OptionsBlockGoldenVectorTest.java
@@ -38,24 +38,26 @@
* interoperability; these bytes are the contract, and a change to the TLV layout has to break every
* copy of them together.
*
- * <p>{@code enforce_fsync} (a one-byte {@code Bool}) and {@code segment_size} (an eight-byte
- * {@code Uint64}) cover both value widths. What the vector pins is the per-entry byte layout, not a
- * key order: these two land sorted only because the Rust core holds options in a {@code BTreeMap},
- * and the server accepts the insertion order this SDK emits.
+ * <p>The vector covers Bool, Uint64 and String values in insertion order. It deliberately differs
+ * from Rust's sorted map order. Decoders accept either order and pin the same per-entry layout.
*/
class OptionsBlockGoldenVectorTest {
private static final byte[] GOLDEN_OPTIONS_BLOCK = {
- 2, 13, 0, 0, 0, 'e', 'n', 'f', 'o', 'r', 'c', 'e', '_', 'f', 's', 'y', 'n', 'c', 3, 1, 0, 0, 0, 1, 2, 12, 0, 0,
- 0, 's', 'e', 'g', 'm', 'e', 'n', 't', '_', 's', 'i', 'z', 'e', 12, 8, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0
+ 2, 20, 0, 0, 0, 'p', 'r', 'e', 'a', 'l', 'l', 'o', 'c', 'a', 't', 'e', '_', 's', 'e', 'g', 'm', 'e', 'n', 't',
+ 's', 3, 1, 0, 0, 0, 1, 2, 12, 0, 0, 0, 's', 'e', 'g', 'm', 'e', 'n', 't', '_', 's', 'i', 'z', 'e', 12, 8, 0, 0,
+ 0, 0, 0, 0, 64, 0, 0, 0, 0, 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112,
+ 101, 114, 115, 105, 115, 116, 101, 100
};
@Test
void shouldEncodeTheCrossSdkGoldenVector() {
Map<HeaderKey, HeaderValue> options = new LinkedHashMap<>();
- options.put(HeaderKey.fromString("enforce_fsync"), HeaderValue.fromBool(true));
+ options.put(HeaderKey.fromString("preallocate_segments"), HeaderValue.fromBool(true));
options.put(HeaderKey.fromString("segment_size"), HeaderValue.fromUint64(BigInteger.valueOf(1_073_741_824L)));
+ options.put(HeaderKey.fromString("durability"), HeaderValue.fromString("persisted"));
+
ByteBuf encoded = BytesSerializer.toBytes(options);
byte[] bytes = new byte[encoded.readableBytes()];
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/topic/TopicOptionsTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/topic/TopicOptionsTest.java
index 6d68972..a958627 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/topic/TopicOptionsTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/topic/TopicOptionsTest.java
@@ -38,18 +38,20 @@
@Test
void shouldEmitOnlyTheKeysThatWereSet() {
- var options = TopicOptions.builder().enforceFsync(true).build();
+ var options = TopicOptions.builder()
+ .durability(org.apache.iggy.topic.Durability.PERSISTED)
+ .build();
- assertThat(options).containsOnlyKeys("enforce_fsync");
- assertThat(options.get("enforce_fsync").kind()).isEqualTo(HeaderKind.Bool);
- assertThat(options.get("enforce_fsync").value()).containsExactly(1);
+ assertThat(options).containsOnlyKeys("durability", "consumer_offset_durability");
+ assertThat(options.get("durability").kind()).isEqualTo(HeaderKind.String);
+ assertThat(options.get("durability").toStringValue()).isEqualTo("persisted");
}
@Test
void shouldEncodeEveryKeyInItsCatalogKind() {
var options = TopicOptions.builder()
.segmentSize(BigInteger.valueOf(134_217_728))
- .enforceFsync(false)
+ .durability(org.apache.iggy.topic.Durability.REPLICATED)
.messagesRequiredToSave(1024)
.sizeOfMessagesRequiredToSave(BigInteger.valueOf(1_048_576))
.preallocateSegments(true)
@@ -58,7 +60,8 @@
assertThat(options)
.containsOnlyKeys(
"segment_size",
- "enforce_fsync",
+ "durability",
+ "consumer_offset_durability",
"messages_required_to_save",
"size_of_messages_required_to_save",
"preallocate_segments");
@@ -75,10 +78,11 @@
var options = TopicOptions.builder()
.preallocateSegments(true)
.segmentSize(BigInteger.valueOf(134_217_728))
- .enforceFsync(false)
+ .durability(org.apache.iggy.topic.Durability.REPLICATED)
.build();
- assertThat(options.keySet()).containsExactly("preallocate_segments", "segment_size", "enforce_fsync");
+ assertThat(options.keySet())
+ .containsExactly("durability", "consumer_offset_durability", "preallocate_segments", "segment_size");
}
@Test
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index 10bbf5b..87e2616 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "apache-iggy",
- "version": "0.10.0-edge.6",
+ "version": "0.10.0-edge.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "apache-iggy",
- "version": "0.10.0-edge.6",
+ "version": "0.10.0-edge.7",
"license": "Apache-2.0",
"dependencies": {
"@node-rs/xxhash": "1.7.7",
diff --git a/foreign/node/package.json b/foreign/node/package.json
index ea4ec4d..80d0ec6 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -1,7 +1,7 @@
{
"name": "apache-iggy",
"type": "module",
- "version": "0.10.0-edge.6",
+ "version": "0.10.0-edge.7",
"description": "Official Apache Iggy NodeJS SDK",
"keywords": [
"iggy",
diff --git a/foreign/node/src/wire/message/send-messages.command.ts b/foreign/node/src/wire/message/send-messages.command.ts
index 99c5981..62965b2 100644
--- a/foreign/node/src/wire/message/send-messages.command.ts
+++ b/foreign/node/src/wire/message/send-messages.command.ts
@@ -59,9 +59,8 @@
*
* Delivery is at-least-once, so an earlier retry of the same batch may
* already have committed at a lower offset: this never identifies a batch
- * uniquely. A batch is confirmed once it is committed in memory, not once it
- * is fsynced, so a crash-restart can stamp a later batch with an offset a
- * client has already recorded.
+ * uniquely. Confirmation follows VSR quorum commit. Persisted message
+ * durability also requires recoverable stable-storage copies on the quorum.
*/
baseOffset: bigint,
};
diff --git a/foreign/node/src/wire/options.utils.test.ts b/foreign/node/src/wire/options.utils.test.ts
index 7fbc381..e2c399f 100644
--- a/foreign/node/src/wire/options.utils.test.ts
+++ b/foreign/node/src/wire/options.utils.test.ts
@@ -34,23 +34,26 @@
*
* Rust pins the identical bytes in `core/binary_protocol/src/primitives/options.rs`,
* as do the Go and Java SDKs. Round-tripping through this SDK's own decoder proves
- * nothing about interoperability; these bytes are the contract.
+ * nothing about interoperability. This insertion-order vector covers Bool, Uint64
+ * and String values, independently of Rust's sorted map order.
*/
const GOLDEN_OPTIONS_BLOCK = Buffer.from([
- 2, 13, 0, 0, 0,
- ...Buffer.from('enforce_fsync'),
+ 2, 20, 0, 0, 0,
+ ...Buffer.from('preallocate_segments'),
3, 1, 0, 0, 0, 1,
2, 12, 0, 0, 0,
...Buffer.from('segment_size'),
12, 8, 0, 0, 0,
- 0, 0, 0, 64, 0, 0, 0, 0
+ 0, 0, 0, 64, 0, 0, 0, 0,
+ 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101, 114, 115, 105, 115, 116, 101, 100
]);
describe('serializeOptions', () => {
it('encodes the cross-SDK golden vector byte for byte', () => {
const encoded = serializeOptions([
- { key: 'enforce_fsync', value: HeaderValue.Bool(true) },
- { key: 'segment_size', value: HeaderValue.Uint64(1_073_741_824n) }
+ { key: 'preallocate_segments', value: HeaderValue.Bool(true) },
+ { key: 'segment_size', value: HeaderValue.Uint64(1_073_741_824n) },
+ { key: 'durability', value: HeaderValue.String('persisted') }
]);
assert.deepEqual(encoded, GOLDEN_OPTIONS_BLOCK);
diff --git a/foreign/node/src/wire/topic/create-topic.command.test.ts b/foreign/node/src/wire/topic/create-topic.command.test.ts
index 0ca484a..868d00a 100644
--- a/foreign/node/src/wire/topic/create-topic.command.test.ts
+++ b/foreign/node/src/wire/topic/create-topic.command.test.ts
@@ -17,6 +17,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
+import { Durability } from './topic.utils.js';
import { CREATE_TOPIC } from './create-topic.command.js';
import { deserializeOptions } from '../options.utils.js';
import { HeaderValue } from '../message/header.utils.js';
@@ -37,13 +38,15 @@
// TLV field: [kind:u8][len:u32_le][bytes]
const tlvSize = (bytes: number) => 1 + 4 + bytes;
const identifierSize = 1 + 1 + 4; // numeric stream id
+ const defaultPolicySize = tlvSize('durability'.length) + tlvSize('replicated'.length)
+ + tlvSize('consumer_offset_durability'.length) + tlvSize('replicated'.length);
const fixedSize = identifierSize + 4 + 1; // + partitions_count + name_len
it('serialize name and default options into buffer', () => {
- // Server-default sentinels are omitted, leaving an empty options block.
+ // Durability defaults are explicit, while the other sentinels are omitted.
assert.deepEqual(
CREATE_TOPIC.serialize(t1).length,
- fixedSize + t1.name.length
+ fixedSize + t1.name.length + defaultPolicySize
);
});
@@ -52,7 +55,7 @@
const b = CREATE_TOPIC.serialize(t);
assert.equal(b.readUInt32LE(identifierSize), 7);
assert.equal(b.readUInt8(identifierSize + 4), t.name.length);
- assert.equal(b.subarray(fixedSize).toString(), t.name);
+ assert.equal(b.subarray(fixedSize, fixedSize + t.name.length).toString(), t.name);
});
it('serialize non-default options into buffer', () => {
@@ -64,7 +67,7 @@
};
assert.deepEqual(
CREATE_TOPIC.serialize(t).length,
- fixedSize + t1.name.length
+ fixedSize + t1.name.length + defaultPolicySize
+ tlvSize('compression_algorithm'.length) + tlvSize('gzip'.length)
+ tlvSize('message_expiry'.length) + tlvSize(8)
+ tlvSize('max_topic_size'.length) + tlvSize(8)
@@ -75,16 +78,16 @@
const t = {
...t1,
segmentSize: 1048576n,
- enforceFsync: true,
+ durability: Durability.Persisted,
messagesRequiredToSave: 1000,
sizeOfMessagesRequiredToSave: 4096n,
preallocateSegments: false
};
assert.deepEqual(
CREATE_TOPIC.serialize(t).length,
- fixedSize + t1.name.length
+ fixedSize + t1.name.length + defaultPolicySize
+ tlvSize('segment_size'.length) + tlvSize(8)
- + tlvSize('enforce_fsync'.length) + tlvSize(1)
+ + 'persisted'.length - 'replicated'.length
+ tlvSize('messages_required_to_save'.length) + tlvSize(4)
+ tlvSize('size_of_messages_required_to_save'.length) + tlvSize(8)
+ tlvSize('preallocate_segments'.length) + tlvSize(1)
@@ -96,7 +99,7 @@
...t1,
maxTopicSize: 4096n,
options: [
- { key: 'enforce_fsync', value: HeaderValue.Bool(true) },
+ { key: 'preallocate_segments', value: HeaderValue.Bool(true) },
// The typed field covers this key, so the caller's entry is dropped:
// a duplicate key makes the server refuse the whole block.
{ key: 'max_topic_size', value: HeaderValue.String('1 GiB') }
@@ -107,11 +110,30 @@
// The create payload runs its options block to the end, unprefixed.
const options = deserializeOptions(b, fixedSize + t.name.length);
- assert.deepEqual(Object.keys(options).sort(), ['enforce_fsync', 'max_topic_size']);
- assert.equal(options.enforce_fsync, true);
+ assert.deepEqual(Object.keys(options).sort(), ['consumer_offset_durability', 'durability', 'max_topic_size', 'preallocate_segments']);
+ assert.equal(options.preallocate_segments, true);
assert.equal(options.max_topic_size, 4096n);
});
+ it('keeps each omitted durability policy replicated', () => {
+ for (const selected of [
+ { durability: Durability.Persisted },
+ { consumerOffsetDurability: Durability.Persisted }
+ ]) {
+ const input = { ...t1, ...selected };
+ const encoded = CREATE_TOPIC.serialize(input);
+ const options = deserializeOptions(encoded, fixedSize + input.name.length);
+ assert.equal(options.durability, selected.durability ?? 'replicated');
+ assert.equal(options.consumer_offset_durability, selected.consumerOffsetDurability ?? 'replicated');
+ }
+ });
+
+ it('rejects a conflicting raw durability instead of weakening it', () => {
+ assert.throws(() => CREATE_TOPIC.serialize({ ...t1, options: [
+ { key: 'durability', value: HeaderValue.String('persisted') }
+ ] }));
+ });
+
it('throw on name < 1', () => {
const t = { ...t1, name: '' };
assert.throws(
diff --git a/foreign/node/src/wire/topic/create-topic.command.ts b/foreign/node/src/wire/topic/create-topic.command.ts
index bdbd9d2..cc57d39 100644
--- a/foreign/node/src/wire/topic/create-topic.command.ts
+++ b/foreign/node/src/wire/topic/create-topic.command.ts
@@ -22,7 +22,7 @@
import { dedupeOptions, serializeOptions, type OptionEntry } from '../options.utils.js';
import { HeaderValue } from '../message/header.utils.js';
import {
- isValidCompressionAlgorithm, CompressionAlgorithm,
+ isValidCompressionAlgorithm, CompressionAlgorithm, Durability,
compressionAlgorithmName,
deserializeTopic,
type Topic,
@@ -56,8 +56,9 @@
maxTopicSize?: bigint,
/** Segment size in bytes: 512-byte multiple between 1 MiB and 1 GiB */
segmentSize?: bigint,
- /** Fsync every write instead of leaving it to the page cache */
- enforceFsync?: boolean,
+ /** Message completion policy. Defaults to replicated, independently of offsets. */
+ durability?: Durability,
+ consumerOffsetDurability?: Durability,
/** Message count that triggers a save (must be non-zero) */
messagesRequiredToSave?: number,
/** Accumulated message bytes that trigger a save */
@@ -90,7 +91,8 @@
messageExpiry = 0n,
maxTopicSize = 0n,
segmentSize,
- enforceFsync,
+ durability = Durability.Replicated,
+ consumerOffsetDurability = Durability.Replicated,
messagesRequiredToSave,
sizeOfMessagesRequiredToSave,
preallocateSegments,
@@ -130,10 +132,19 @@
options.push({
key: 'segment_size', value: HeaderValue.Uint64(segmentSize)
});
- if (enforceFsync !== undefined)
- options.push({
- key: 'enforce_fsync', value: HeaderValue.Bool(enforceFsync)
- });
+ for (const [key, policy] of [
+ ['durability', durability],
+ ['consumer_offset_durability', consumerOffsetDurability]
+ ] as const) {
+ if (policy !== Durability.Replicated && policy !== Durability.Persisted)
+ throw new Error(`Invalid ${key}: ${policy}`);
+ const expected = HeaderValue.String(policy);
+ for (const entry of extraOptions) {
+ if (entry.key === key && (entry.value.kind !== expected.kind || entry.value.value !== expected.value))
+ throw new Error(`Conflicting ${key}`);
+ }
+ options.push({ key, value: expected });
+ }
if (messagesRequiredToSave !== undefined)
options.push({
key: 'messages_required_to_save',
diff --git a/foreign/node/src/wire/topic/index.ts b/foreign/node/src/wire/topic/index.ts
index 8baa49a..7569700 100644
--- a/foreign/node/src/wire/topic/index.ts
+++ b/foreign/node/src/wire/topic/index.ts
@@ -22,4 +22,4 @@
export * from './purge-topic.command.js';
export * from './update-topic.command.js';
export * from './ensure-topic.virtual.command.js';
-export { CompressionAlgorithm } from './topic.utils.js';
+export { CompressionAlgorithm, Durability } from './topic.utils.js';
diff --git a/foreign/node/src/wire/topic/topic.utils.ts b/foreign/node/src/wire/topic/topic.utils.ts
index 6f35e79..e691a1c 100644
--- a/foreign/node/src/wire/topic/topic.utils.ts
+++ b/foreign/node/src/wire/topic/topic.utils.ts
@@ -240,3 +240,10 @@
}
return topics;
};
+
+export const Durability = {
+ Replicated: 'replicated',
+ Persisted: 'persisted'
+} as const;
+
+export type Durability = typeof Durability[keyof typeof Durability];
diff --git a/foreign/php/Cargo.toml b/foreign/php/Cargo.toml
index fad3fcd..ca70405 100644
--- a/foreign/php/Cargo.toml
+++ b/foreign/php/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "iggy-php"
-version = "0.1.2"
+version = "0.1.3"
edition = "2024"
authors = ["Iggy Committers <dev@iggy.apache.org>"]
license = "Apache-2.0"
diff --git a/foreign/php/iggy-php.stubs.php b/foreign/php/iggy-php.stubs.php
index be8437c..62c012c 100644
--- a/foreign/php/iggy-php.stubs.php
+++ b/foreign/php/iggy-php.stubs.php
@@ -135,13 +135,14 @@
* @param int|null $message_expiry_micros
* @param int|null $max_topic_size
* @param int|null $segment_size
- * @param bool|null $enforce_fsync
+ * @param \Iggy\Durability|null $durability
+ * @param \Iggy\Durability|null $consumer_offset_durability
* @param int|null $messages_required_to_save
* @param int|null $size_of_messages_required_to_save
* @param bool|null $preallocate_segments
* @return void
*/
- public function createTopic(mixed $stream, string $name, int $partitions_count, ?string $compression_algorithm = null, ?int $message_expiry_micros = null, ?int $max_topic_size = null, ?int $segment_size = null, ?bool $enforce_fsync = null, ?int $messages_required_to_save = null, ?int $size_of_messages_required_to_save = null, ?bool $preallocate_segments = null): void {}
+ public function createTopic(mixed $stream, string $name, int $partitions_count, ?string $compression_algorithm = null, ?int $message_expiry_micros = null, ?int $max_topic_size = null, ?int $segment_size = null, ?\Iggy\Durability $durability = null, ?\Iggy\Durability $consumer_offset_durability = null, ?int $messages_required_to_save = null, ?int $size_of_messages_required_to_save = null, ?bool $preallocate_segments = null): void {}
/**
* Deletes a stream by id or name.
@@ -331,6 +332,11 @@
public function topic(): string {}
}
+ enum Durability: string {
+ case Replicated = 'replicated';
+ case Persisted = 'persisted';
+ }
+
class MessageIterator implements \Iterator {
public function __construct() {}
@@ -515,9 +521,8 @@
* Delivery is at-least-once, so an earlier retry may already have committed the
* same batch at a lower offset. The value never implies uniqueness.
*
- * A batch is confirmed once it is committed in memory, not once it is fsynced. A
- * crash-restart can stamp a later batch with an offset a client has already
- * recorded.
+ * Confirmation follows VSR quorum commit. Persisted message durability also
+ * requires recoverable stable-storage copies on the quorum.
*
* @var int
*/
diff --git a/foreign/php/src/client.rs b/foreign/php/src/client.rs
index 23e5a61..fbe8d0b 100644
--- a/foreign/php/src/client.rs
+++ b/foreign/php/src/client.rs
@@ -27,6 +27,7 @@
use tokio::sync::Mutex;
use crate::consumer::{AutoCommit, IggyConsumer};
+use crate::durability::Durability as PhpDurability;
use crate::error::to_php_exception;
use crate::identifier::PhpIdentifier;
use crate::receive_message::{PollingStrategy, ReceiveMessage};
@@ -136,7 +137,8 @@
message_expiry_micros: Option<u64>,
max_topic_size: Option<u64>,
segment_size: Option<u64>,
- enforce_fsync: Option<bool>,
+ durability: Option<PhpDurability>,
+ consumer_offset_durability: Option<PhpDurability>,
messages_required_to_save: Option<u32>,
size_of_messages_required_to_save: Option<u64>,
preallocate_segments: Option<bool>,
@@ -161,7 +163,8 @@
message_expiry: (expiry != IggyExpiry::ServerDefault).then_some(expiry),
max_topic_size: (max_size != MaxTopicSize::ServerDefault).then_some(max_size),
segment_size: segment_size.map(IggyByteSize::from),
- enforce_fsync,
+ durability: durability.unwrap_or_default().into(),
+ consumer_offset_durability: consumer_offset_durability.unwrap_or_default().into(),
messages_required_to_save,
size_of_messages_required_to_save: size_of_messages_required_to_save
.map(IggyByteSize::from),
diff --git a/foreign/php/src/durability.rs b/foreign/php/src/durability.rs
new file mode 100644
index 0000000..9cdaa7e
--- /dev/null
+++ b/foreign/php/src/durability.rs
@@ -0,0 +1,38 @@
+// 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 ext_php_rs::php_enum;
+
+#[php_enum]
+#[php(name = "Iggy\\Durability")]
+#[derive(Clone, Copy, Default)]
+pub enum Durability {
+ #[default]
+ #[php(value = "replicated")]
+ Replicated,
+ #[php(value = "persisted")]
+ Persisted,
+}
+
+impl From<Durability> for iggy::prelude::Durability {
+ fn from(value: Durability) -> Self {
+ match value {
+ Durability::Replicated => Self::Replicated,
+ Durability::Persisted => Self::Persisted,
+ }
+ }
+}
diff --git a/foreign/php/src/lib.rs b/foreign/php/src/lib.rs
index 49b9442..02bc6a9 100644
--- a/foreign/php/src/lib.rs
+++ b/foreign/php/src/lib.rs
@@ -17,6 +17,7 @@
pub mod client;
pub mod consumer;
+pub mod durability;
pub mod error;
pub mod identifier;
pub mod message_iterator;
@@ -43,6 +44,7 @@
#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
module
+ .enumeration::<durability::Durability>()
// Parent classes must be registered before subclasses because ext-php-rs resolves
// the parent ClassEntry during child registration.
.class::<IggyException>()
diff --git a/foreign/php/src/send_message.rs b/foreign/php/src/send_message.rs
index 19b09a2..476562a 100644
--- a/foreign/php/src/send_message.rs
+++ b/foreign/php/src/send_message.rs
@@ -151,9 +151,8 @@
/// Delivery is at-least-once, so an earlier retry may already have committed the
/// same batch at a lower offset. The value never implies uniqueness.
///
- /// A batch is confirmed once it is committed in memory, not once it is fsynced. A
- /// crash-restart can stamp a later batch with an offset a client has already
- /// recorded.
+ /// Confirmation follows VSR quorum commit. Persisted message durability also
+ /// requires recoverable stable-storage copies on the quorum.
#[php(getter)]
pub fn base_offset(&self) -> u64 {
self.inner.base_offset
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index 1430a98..0471a48 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "apache-iggy"
-version = "0.9.0-dev7"
+version = "0.9.0-dev8"
edition = "2024"
authors = ["Iggy Committers <dev@iggy.apache.org>"]
license = "Apache-2.0"
@@ -37,7 +37,7 @@
[dependencies]
bytes = "1.12.1"
futures = "0.3.34"
-iggy = { path = "../../core/sdk", version = "0.11.0-edge.7" }
+iggy = { path = "../../core/sdk", version = "0.11.0-edge.8" }
paste = "1"
pyo3 = "0.29.2"
pyo3-async-runtimes = { version = "0.29.0", features = [
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index adebabe..532c0d0 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -26,6 +26,7 @@
import typing
__all__ = [
+ "Durability",
"AutoCommit",
"AutoCommitAfter",
"AutoCommitWhen",
@@ -1313,7 +1314,8 @@
message_expiry: IggyExpiry | None = None,
max_topic_size: MaxTopicSize | None = None,
segment_size: builtins.int | None = None,
- enforce_fsync: builtins.bool | None = None,
+ durability: Durability | None = None,
+ consumer_offset_durability: Durability | None = None,
messages_required_to_save: builtins.int | None = None,
size_of_messages_required_to_save: builtins.int | None = None,
preallocate_segments: builtins.bool | None = None,
@@ -1330,7 +1332,8 @@
message_expiry: Message expiry as `IggyExpiry | None`.
max_topic_size: Maximum topic size as `MaxTopicSize | None`.
segment_size: Per-topic segment size in bytes as `int | None`.
- enforce_fsync: Per-topic fsync enforcement as `bool | None`.
+ durability: Message completion policy, defaulting to replicated.
+ consumer_offset_durability: Independent offset policy, defaulting to replicated.
messages_required_to_save: Message-count flush threshold as `int | None`.
size_of_messages_required_to_save: Byte flush threshold as `int | None`.
preallocate_segments: Reserve segment bytes on open as `bool | None`.
@@ -2327,9 +2330,8 @@
at-least-once, so an earlier retry may already have committed these
messages at a lower offset.
- A batch is confirmed once it is committed in memory, not once it is
- fsynced. A crash-restart can stamp a later batch with an offset a client
- has already recorded.
+ Confirmation follows VSR quorum commit. A topic with persisted message
+ durability also waits for recoverable stable-storage copies on the quorum.
The legacy server confirms nothing, so its confirmation list is empty
and this value is never reached.
@@ -2351,9 +2353,8 @@
A reported `base_offset` never implies uniqueness, because delivery is
at-least-once and an earlier retry may already have committed the same
- messages at a lower offset. A batch is confirmed once it is committed in
- memory, not once it is fsynced. A crash-restart can stamp a later batch
- with an offset a client has already recorded.
+ messages at a lower offset. Confirmation follows the topic's message
+ durability policy: quorum commit, plus stable storage for persisted topics.
"""
@typing.final
@@ -3265,3 +3266,7 @@
r"""
The user account is inactive and cannot be used.
"""
+
+class Durability(str, enum.Enum):
+ REPLICATED = "replicated"
+ PERSISTED = "persisted"
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index 0ed0dae..2a51c83 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -22,7 +22,7 @@
[project]
name = "apache-iggy"
requires-python = ">=3.10"
-version = "0.9.0.dev7"
+version = "0.9.0.dev8"
description = "Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
readme = "README.md"
license = { file = "LICENSE" }
diff --git a/foreign/python/src/bin/stub_gen.rs b/foreign/python/src/bin/stub_gen.rs
index 82693da..1873a0f 100644
--- a/foreign/python/src/bin/stub_gen.rs
+++ b/foreign/python/src/bin/stub_gen.rs
@@ -51,10 +51,12 @@
// a cwd-relative path leaves the tracked stub without its license header.
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("apache_iggy.pyi");
let mut f = File::open(&path)?;
- let mut content = LICENSE.as_bytes().to_owned();
- f.read_to_end(&mut content)?;
+ let mut content = LICENSE.to_owned();
+ f.read_to_string(&mut content)?;
+ content = content.replacen("__all__ = [", "__all__ = [\n \"Durability\",", 1);
+ content.push_str("\nclass Durability(str, enum.Enum):\n REPLICATED = 'replicated'\n PERSISTED = 'persisted'\n");
let mut f = File::create(path)?;
- f.write_all(content.as_slice())?;
+ f.write_all(content.as_bytes())?;
Ok(())
}
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 0e3a7b9..6ce2e8c 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -686,7 +686,8 @@
/// message_expiry: Message expiry as `IggyExpiry | None`.
/// max_topic_size: Maximum topic size as `MaxTopicSize | None`.
/// segment_size: Per-topic segment size in bytes as `int | None`.
- /// enforce_fsync: Per-topic fsync enforcement as `bool | None`.
+ /// durability: Message completion policy, defaulting to replicated.
+ /// consumer_offset_durability: Independent offset policy, defaulting to replicated.
/// messages_required_to_save: Message-count flush threshold as `int | None`.
/// size_of_messages_required_to_save: Byte flush threshold as `int | None`.
/// preallocate_segments: Reserve segment bytes on open as `bool | None`.
@@ -703,7 +704,7 @@
/// ValueError: If `message_expiry` or `max_topic_size` is out of range.
/// PyRuntimeError: If another argument is invalid or the request fails.
#[pyo3(
- signature = (stream, name, partitions_count, compression_algorithm = None, message_expiry = None, max_topic_size = None, segment_size = None, enforce_fsync = None, messages_required_to_save = None, size_of_messages_required_to_save = None, preallocate_segments = None, options = None)
+ signature = (stream, name, partitions_count, compression_algorithm = None, message_expiry = None, max_topic_size = None, segment_size = None, durability = None, consumer_offset_durability = None, messages_required_to_save = None, size_of_messages_required_to_save = None, preallocate_segments = None, options = None)
)]
#[allow(clippy::too_many_arguments)]
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
@@ -723,7 +724,11 @@
&MaxTopicSize,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] segment_size: Option<u64>,
- #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enforce_fsync: Option<bool>,
+ #[gen_stub(override_type(type_repr = "Durability | None"))] durability: Option<
+ &Bound<'_, PyAny>,
+ >,
+ #[gen_stub(override_type(type_repr = "Durability | None"))]
+ consumer_offset_durability: Option<&Bound<'_, PyAny>>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
messages_required_to_save: Option<u32>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
@@ -746,7 +751,11 @@
message_expiry: (expiry != RustIggyExpiry::ServerDefault).then_some(expiry),
max_topic_size: (max_size != RustMaxTopicSize::ServerDefault).then_some(max_size),
segment_size: segment_size.map(IggyByteSize::from),
- enforce_fsync,
+ durability: crate::durability::Durability::try_from(durability)?.0,
+ consumer_offset_durability: crate::durability::Durability::try_from(
+ consumer_offset_durability,
+ )?
+ .0,
messages_required_to_save,
size_of_messages_required_to_save: size_of_messages_required_to_save
.map(IggyByteSize::from),
diff --git a/foreign/python/src/durability.rs b/foreign/python/src/durability.rs
new file mode 100644
index 0000000..9cbd1cb
--- /dev/null
+++ b/foreign/python/src/durability.rs
@@ -0,0 +1,58 @@
+// 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 iggy::prelude::Durability as RustDurability;
+use pyo3::{
+ exceptions::PyTypeError,
+ prelude::*,
+ types::{PyDict, PyString},
+};
+
+pub struct Durability(pub RustDurability);
+
+impl Durability {
+ pub fn register(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
+ let kwargs = PyDict::new(py);
+ kwargs.set_item("type", py.get_type::<PyString>())?;
+ kwargs.set_item("module", "apache_iggy")?;
+ let members = [("REPLICATED", "replicated"), ("PERSISTED", "persisted")];
+ let enumeration = py
+ .import("enum")?
+ .getattr("Enum")?
+ .call(("Durability", members), Some(&kwargs))?;
+ module.add("Durability", enumeration)
+ }
+}
+
+impl TryFrom<Option<&Bound<'_, PyAny>>> for Durability {
+ type Error = PyErr;
+
+ fn try_from(value: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
+ let Some(value) = value else {
+ return Ok(Self(RustDurability::Replicated));
+ };
+ let class = value.py().import("apache_iggy")?.getattr("Durability")?;
+ if !value.is_instance(&class)? {
+ return Err(PyTypeError::new_err("Expected Durability"));
+ }
+ let token: String = value.getattr("value")?.extract()?;
+ token
+ .parse()
+ .map(Self)
+ .map_err(|_| PyTypeError::new_err("Invalid durability"))
+ }
+}
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index dc23883..4dbcd48 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -18,6 +18,7 @@
pub mod client;
mod config;
mod consumer;
+mod durability;
mod duration;
mod identifier;
mod options;
@@ -54,7 +55,8 @@
/// Python client for Apache Iggy, the persistent message streaming platform.
#[pymodule]
-fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
+fn apache_iggy(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
+ durability::Durability::register(py, m)?;
m.add_class::<SendMessage>()?;
m.add_class::<SendMessagesResponse>()?;
m.add_class::<SendMessagesConfirmation>()?;
diff --git a/foreign/python/src/send_message.rs b/foreign/python/src/send_message.rs
index bfa3cdd..f0eba1a 100644
--- a/foreign/python/src/send_message.rs
+++ b/foreign/python/src/send_message.rs
@@ -146,9 +146,8 @@
/// at-least-once, so an earlier retry may already have committed these
/// messages at a lower offset.
///
- /// A batch is confirmed once it is committed in memory, not once it is
- /// fsynced. A crash-restart can stamp a later batch with an offset a client
- /// has already recorded.
+ /// Confirmation follows VSR quorum commit. A topic with persisted message
+ /// durability also waits for recoverable stable-storage copies on the quorum.
///
/// The legacy server confirms nothing, so its confirmation list is empty
/// and this value is never reached.
@@ -182,9 +181,8 @@
///
/// A reported `base_offset` never implies uniqueness, because delivery is
/// at-least-once and an earlier retry may already have committed the same
- /// messages at a lower offset. A batch is confirmed once it is committed in
- /// memory, not once it is fsynced. A crash-restart can stamp a later batch
- /// with an offset a client has already recorded.
+ /// messages at a lower offset. Confirmation follows the topic's message
+ /// durability policy: quorum commit, plus stable storage for persisted topics.
#[getter]
pub fn confirmations(&self) -> Vec<SendMessagesConfirmation> {
self.inner
diff --git a/foreign/python/tests/test_topic.py b/foreign/python/tests/test_topic.py
index f942956..7390cb3 100644
--- a/foreign/python/tests/test_topic.py
+++ b/foreign/python/tests/test_topic.py
@@ -19,7 +19,14 @@
import pytest
-from apache_iggy import HeaderValue, IggyClient, IggyExpiry, MaxTopicSize, SendMessage
+from apache_iggy import (
+ Durability,
+ HeaderValue,
+ IggyClient,
+ IggyExpiry,
+ MaxTopicSize,
+ SendMessage,
+)
from .utils import (
get_server_config,
@@ -1380,7 +1387,8 @@
stream=stream_name,
name=topic_name,
partitions_count=1,
- options={"enforce_fsync": "true", "segment_size": "128 MiB"},
+ durability=Durability.PERSISTED,
+ options={"segment_size": "128 MiB"},
)
topic = await iggy_client.get_topic(stream_name, topic_name)
@@ -1388,17 +1396,17 @@
# Options come back through the same typed dict message user headers
# use, so the scalar helper reads them the same way.
explicit = topic.options.to_scalar_dict()
- assert explicit["enforce_fsync"] is True
+ assert explicit["durability"] == "persisted"
assert explicit["segment_size"] == 128 * 1024 * 1024
# Keys the client left alone are resolved by admission and reported
# separately, so an operator can tell chosen from defaulted.
derived = topic.derived_options.to_scalar_dict()
assert "max_topic_size" in derived
- assert "enforce_fsync" not in derived
+ assert "durability" not in derived
topics = await iggy_client.get_topics(stream_name)
listed = next(entry for entry in topics if entry.name == topic_name)
- assert listed.options.to_scalar_dict()["enforce_fsync"] is True
+ assert listed.options.to_scalar_dict()["durability"] == "persisted"
@pytest.mark.asyncio
async def test_update_topic_options_reach_the_server(
@@ -1443,7 +1451,8 @@
by_key = {spec.key: spec for spec in specs}
assert "segment_size" in by_key
- assert "enforce_fsync" in by_key
+ assert "durability" in by_key
+ assert "consumer_offset_durability" in by_key
segment_size = by_key["segment_size"]
assert segment_size.kind == "uint64"
# The default is the same HeaderValue type message headers carry, so it
diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock
index 0c1d257..4f344fc 100644
--- a/foreign/python/uv.lock
+++ b/foreign/python/uv.lock
@@ -8,7 +8,7 @@
[[package]]
name = "apache-iggy"
-version = "0.9.0.dev7"
+version = "0.9.0.dev8"
source = { editable = "." }
[package.optional-dependencies]
diff --git a/helm/charts/iggy/README.md b/helm/charts/iggy/README.md
index 8026d99..45eef8b 100644
--- a/helm/charts/iggy/README.md
+++ b/helm/charts/iggy/README.md
@@ -203,7 +203,11 @@
cluster: a node that authenticates cannot talk to one that does not.
* **`server.encryption`** encrypts message payloads and state commands at rest
with AES-256-GCM, under a 32-byte base64 key. A node holding a different key
- cannot read what its peers wrote.
+ cannot read what its peers wrote. The chart selects the legacy configuration
+ for versioned images through `0.9.0-edge.7`, including its pinned image.
+ For newer builds with `[encryption]`, set `server.encryption.configVersion=flat`.
+ Custom tags require an explicit `flat` or `legacy` layout; `legacy` uses
+ `[system.encryption]`.
* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a
restart. With `cluster.auth` enabled the server already derives a cluster-wide
key from the replica PSK, so setting the JWT secrets is an alternative to that
@@ -304,7 +308,7 @@
HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh
```
-The smoke script defaults `IGGY_SYSTEM_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources.
+The smoke script defaults `IGGY_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources.
On smoke-test failures you can collect the same diagnostics as CI with:
@@ -488,7 +492,7 @@
| podSecurityContext | object | `{"seccompProfile":{"type":"Unconfined"}}` | Pod security context (server uses io_uring, requires unconfined seccomp) |
| resources | object | `{}` | Resource limits and requests for server |
| securityContext | object | `{"capabilities":{"add":["IPC_LOCK"]}}` | Container security context (server requires IPC_LOCK for io_uring) |
-| server | object | `{"advertisedAddress":"","affinity":{},"cluster":{"auth":{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""},"enabled":false,"name":"iggy-cluster","nodes":[],"requireHostNetwork":true,"selfReplicaId":0},"enabled":true,"encryption":{"enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""},"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"extraArgs":[],"hostNetwork":false,"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":""},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"jwt":{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}},"nodeSelector":{},"persistence":{"accessMode":"ReadWriteOnce","annotations":{},"enabled":false,"existingClaim":"","size":"8Gi","storageClass":""},"ports":{"http":3000,"quic":8080,"tcp":8090,"websocket":8092},"replicaCount":1,"service":{"port":3000,"type":"ClusterIP"},"serviceMonitor":{"additionalLabels":{},"authorization":{},"enabled":false,"honorLabels":false,"interval":"30s","namespace":"","path":"/metrics","scrapeTimeout":"10s"},"strategy":{},"tolerations":[],"users":{"root":{"createSecret":true,"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"password":"changeit","username":"iggy"}}}` | Iggy server configuration |
+| server | object | `{"advertisedAddress":"","affinity":{},"cluster":{"auth":{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""},"enabled":false,"name":"iggy-cluster","nodes":[],"requireHostNetwork":true,"selfReplicaId":0},"enabled":true,"encryption":{"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""},"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"extraArgs":[],"hostNetwork":false,"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":""},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"jwt":{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}},"nodeSelector":{},"persistence":{"accessMode":"ReadWriteOnce","annotations":{},"enabled":false,"existingClaim":"","size":"8Gi","storageClass":""},"ports":{"http":3000,"quic":8080,"tcp":8090,"websocket":8092},"replicaCount":1,"service":{"port":3000,"type":"ClusterIP"},"serviceMonitor":{"additionalLabels":{},"authorization":{},"enabled":false,"honorLabels":false,"interval":"30s","namespace":"","path":"/metrics","scrapeTimeout":"10s"},"strategy":{},"tolerations":[],"users":{"root":{"createSecret":true,"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"password":"changeit","username":"iggy"}}}` | Iggy server configuration |
| server.advertisedAddress | string | `""` | Client-facing address published in cluster metadata. Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` instead also works, but setting both is refused at render time. Empty falls back to the in-cluster Service DNS name. Ignored in cluster mode, where the address comes from the node's roster entry, so setting both is refused there too. |
| server.affinity | object | `{}` | Affinity rules for server pods |
| server.cluster.auth | object | `{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""}` | Replica-to-replica authentication on the consensus port. When enabled every peer must complete an authenticated handshake or be rejected, and a shared secret becomes mandatory. Enabling it on a running cluster is a coordinated-restart change, not a rolling one. |
@@ -504,7 +508,8 @@
| server.cluster.requireHostNetwork | bool | `true` | Refuse to render a cluster node without `server.hostNetwork`. The replica listener binds the roster `ip` verbatim, which no pod owns on the cluster network, so the pod would die at boot with `CannotBindToSocket`. Set this to false only when the roster `ip` is an address the pod itself holds. |
| server.cluster.selfReplicaId | int | `0` | Which `nodes` entry this release runs, matched against `replicaId`. |
| server.enabled | bool | `true` | Enable the Iggy server deployment |
-| server.encryption | object | `{"enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""}` | Server-side encryption of message payloads and state commands, using AES-256-GCM. Every node of a cluster must hold the identical key, or it cannot read data another node wrote. |
+| server.encryption | object | `{"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""}` | Server-side encryption of message payloads and state commands, using AES-256-GCM. Every node of a cluster must hold the identical key, or it cannot read data another node wrote. |
+| server.encryption.configVersion | string | `"auto"` | Encryption config layout: `auto` selects `legacy` for released image versions through 0.9.0-edge.7. Set `flat` for newer server builds, or `legacy` for custom builds that still use `[system.encryption]`. |
| server.encryption.enabled | bool | `false` | Enable encryption at rest |
| server.encryption.existingSecret.key | string | `"encryptionKey"` | Key inside that Secret |
| server.encryption.existingSecret.name | string | `""` | Name of an existing Secret holding the encryption key |
diff --git a/helm/charts/iggy/README.md.gotmpl b/helm/charts/iggy/README.md.gotmpl
index ec1fb50..c5ec48e 100644
--- a/helm/charts/iggy/README.md.gotmpl
+++ b/helm/charts/iggy/README.md.gotmpl
@@ -221,7 +221,11 @@
cluster: a node that authenticates cannot talk to one that does not.
* **`server.encryption`** encrypts message payloads and state commands at rest
with AES-256-GCM, under a 32-byte base64 key. A node holding a different key
- cannot read what its peers wrote.
+ cannot read what its peers wrote. The chart selects the legacy configuration
+ for versioned images through `0.9.0-edge.7`, including its pinned image.
+ For newer builds with `[encryption]`, set `server.encryption.configVersion=flat`.
+ Custom tags require an explicit `flat` or `legacy` layout; `legacy` uses
+ `[system.encryption]`.
* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a
restart. With `cluster.auth` enabled the server already derives a cluster-wide
key from the replica PSK, so setting the JWT secrets is an alternative to that
@@ -322,7 +326,7 @@
HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh
```
-The smoke script defaults `IGGY_SYSTEM_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources.
+The smoke script defaults `IGGY_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources.
On smoke-test failures you can collect the same diagnostics as CI with:
diff --git a/helm/charts/iggy/templates/_helpers.tpl b/helm/charts/iggy/templates/_helpers.tpl
index f6943c8..65e3e74 100644
--- a/helm/charts/iggy/templates/_helpers.tpl
+++ b/helm/charts/iggy/templates/_helpers.tpl
@@ -290,9 +290,24 @@
{{- $server := .Values.server }}
{{- $generated := include "iggy.secretName" . }}
{{- if $server.encryption.enabled }}
-- name: IGGY_SYSTEM_ENCRYPTION_ENABLED
+ {{- $configVersion := $server.encryption.configVersion | default "auto" }}
+ {{- if eq $configVersion "auto" }}
+ {{- $imageTag := $server.image.tag | default .Chart.AppVersion }}
+ {{- if and (regexMatch "^v?[0-9]+\\.[0-9]+\\.[0-9]+([+-].*)?$" $imageTag) (semverCompare "<=0.9.0-edge.7" $imageTag) }}
+ {{- $configVersion = "legacy" }}
+ {{- else }}
+ {{- fail "Cannot determine the encryption config layout for this server image. Set server.encryption.configVersion to flat for builds using [encryption], or legacy for builds using [system.encryption]." }}
+ {{- end }}
+ {{- end }}
+ {{- $prefix := "IGGY_ENCRYPTION" }}
+ {{- if eq $configVersion "legacy" }}
+ {{- $prefix = "IGGY_SYSTEM_ENCRYPTION" }}
+ {{- else if ne $configVersion "flat" }}
+ {{- fail "server.encryption.configVersion must be auto, legacy, or flat." }}
+ {{- end }}
+- name: {{ $prefix }}_ENABLED
value: "true"
-- name: IGGY_SYSTEM_ENCRYPTION_KEY
+- name: {{ $prefix }}_KEY
valueFrom:
secretKeyRef:
name: {{ default $generated $server.encryption.existingSecret.name }}
diff --git a/helm/charts/iggy/values.yaml b/helm/charts/iggy/values.yaml
index ab1a5f7..4c80b6b 100644
--- a/helm/charts/iggy/values.yaml
+++ b/helm/charts/iggy/values.yaml
@@ -109,6 +109,10 @@
encryption:
# -- Enable encryption at rest
enabled: false
+ # -- Encryption config layout: `auto` selects `legacy` for released image
+ # versions through 0.9.0-edge.7. Set `flat` for newer server builds, or
+ # `legacy` for custom builds that still use `[system.encryption]`.
+ configVersion: auto
# -- 32-byte key, base64 encoded. Ignored when `existingSecret.name` is set.
# Prefer `existingSecret` outside development: a value here is stored in the
# Helm release and readable by anyone who can read it.
diff --git a/scripts/ci/license-headers.sh b/scripts/ci/license-headers.sh
index f5cfb87..55d79f0 100755
--- a/scripts/ci/license-headers.sh
+++ b/scripts/ci/license-headers.sh
@@ -79,9 +79,11 @@
exit 1
fi
+# Only the major version must match the pin, so a newer local minor or
+# patch release does not block commits.
INSTALLED_HAWKEYE_VERSION="$(hawkeye -V | awk '{print $2}')"
-if [ "$INSTALLED_HAWKEYE_VERSION" != "$HAWKEYE_VERSION" ]; then
- echo "❌ hawkeye $HAWKEYE_VERSION is required, found ${INSTALLED_HAWKEYE_VERSION:-unknown}"
+if [ "${INSTALLED_HAWKEYE_VERSION%%.*}" != "${HAWKEYE_VERSION%%.*}" ]; then
+ echo "❌ hawkeye ${HAWKEYE_VERSION%%.*}.x is required, found ${INSTALLED_HAWKEYE_VERSION:-unknown}"
echo "💡 Install HawkEye: cargo install hawkeye --version $HAWKEYE_VERSION --locked --force"
exit 1
fi
diff --git a/scripts/ci/test-helm.sh b/scripts/ci/test-helm.sh
index 5315223..c3930d2 100755
--- a/scripts/ci/test-helm.sh
+++ b/scripts/ci/test-helm.sh
@@ -287,6 +287,51 @@
grep -q '^ name: iggy-secrets$' "$HELM_RENDER_DIR/generated-secret.yaml"
grep -q '^ key: encryptionKey$' "$HELM_RENDER_DIR/generated-secret.yaml"
test "$(grep -c '^kind: Secret$' "$HELM_RENDER_DIR/generated-secret.yaml")" -eq 2
+ grep -q 'name: IGGY_SYSTEM_ENCRYPTION_ENABLED' "$HELM_RENDER_DIR/generated-secret.yaml"
+ grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/generated-secret.yaml"
+ if grep -q 'name: IGGY_ENCRYPTION_' "$HELM_RENDER_DIR/generated-secret.yaml"; then
+ echo "Error: the pinned server image requires the legacy encryption variables" >&2
+ exit 1
+ fi
+
+ helm template iggy "$CHART_DIR" \
+ --set-string server.image.tag=0.9.0-edge.7 \
+ --set server.encryption.enabled=true \
+ --set server.encryption.existingSecret.name=shared-key \
+ > "$HELM_RENDER_DIR/legacy-encryption.yaml"
+ grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/legacy-encryption.yaml"
+
+ helm template iggy "$CHART_DIR" \
+ --set-string server.image.tag=custom-flat \
+ --set server.encryption.enabled=true \
+ --set server.encryption.configVersion=flat \
+ --set server.encryption.existingSecret.name=shared-key \
+ > "$HELM_RENDER_DIR/flat-encryption.yaml"
+ grep -q 'name: IGGY_ENCRYPTION_ENABLED' "$HELM_RENDER_DIR/flat-encryption.yaml"
+ grep -q 'name: IGGY_ENCRYPTION_KEY' "$HELM_RENDER_DIR/flat-encryption.yaml"
+ if grep -q 'name: IGGY_SYSTEM_ENCRYPTION_' "$HELM_RENDER_DIR/flat-encryption.yaml"; then
+ echo "Error: a server with flat encryption config rejects legacy variables" >&2
+ exit 1
+ fi
+
+ helm template iggy "$CHART_DIR" \
+ --set-string server.image.tag=custom-legacy \
+ --set server.encryption.enabled=true \
+ --set server.encryption.configVersion=legacy \
+ --set server.encryption.existingSecret.name=shared-key \
+ > "$HELM_RENDER_DIR/custom-legacy-encryption.yaml"
+ grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/custom-legacy-encryption.yaml"
+
+ for image_tag in custom-unknown 0.9.0-edge.8; do
+ assert_render_rejected "encryption config layout unknown for ${image_tag}" \
+ --set-string server.image.tag="$image_tag" \
+ --set server.encryption.enabled=true \
+ --set server.encryption.existingSecret.name=shared-key
+ done
+ assert_render_rejected "invalid encryption config layout" \
+ --set server.encryption.enabled=true \
+ --set server.encryption.configVersion=invalid \
+ --set server.encryption.existingSecret.name=shared-key
assert_render_rejected "server.replicaCount=3" --set server.replicaCount=3
assert_render_rejected "encryption without a key" --set server.encryption.enabled=true
@@ -455,7 +500,7 @@
value: "0.0.0.0:8080"
- name: IGGY_WEBSOCKET_ADDRESS
value: "0.0.0.0:8092"
- - name: IGGY_SYSTEM_SHARDING_CPU_ALLOCATION
+ - name: IGGY_SHARDING_CPU_ALLOCATION
value: "${HELM_SMOKE_SERVER_CPU_ALLOCATION}"
ui:
image:
diff --git a/scripts/performance/run-standard-performance-suite.sh b/scripts/performance/run-standard-performance-suite.sh
index 10244a5..4de8dd2 100755
--- a/scripts/performance/run-standard-performance-suite.sh
+++ b/scripts/performance/run-standard-performance-suite.sh
@@ -59,32 +59,11 @@
trap on_exit_bench SIGINT
trap on_exit_bench EXIT
-# Function to get environment variables based on benchmark type
+# Cache and no_wait names below are historical scenario labels. They do not
+# change server storage settings and must not be compared as cache policies.
+# Each scenario uses the same explicit server credentials.
get_env_vars() {
- local bench_type="$1"
- local env_vars=()
-
- env_vars+=("IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy")
-
- # Specific env vars based on bench type
- case "$bench_type" in
- # fsync is a topic creation option (`enforce_fsync`) now, not server config,
- # so the bench command carries `--enforce-fsync` (added by
- # `construct_bench_command` off the same remark) and only the cache setting
- # is left to the server environment.
- *"no_cache_fsync"*)
- env_vars+=("IGGY_SYSTEM_CACHE_ENABLED=false")
- ;;
- *"only_cache"*)
- env_vars+=("IGGY_SYSTEM_CACHE_SIZE=9GB")
- ;;
- *"no_cache"*)
- env_vars+=("IGGY_SYSTEM_CACHE_ENABLED=false")
- ;;
- *"no_wait"*)
- env_vars+=("IGGY_SYSTEM_SEGMENT_SERVER_CONFIRMATION=no_wait")
- ;;
- esac
+ local env_vars=("IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy")
# Convert array to env var string
local env_string=""
@@ -106,34 +85,34 @@
##############################
# Large batch tests with cache enabled
-NORMAL_BATCH_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with forced cache
-NORMAL_BATCH_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "poll_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with forced cache
+NORMAL_BATCH_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
+NORMAL_BATCH_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "poll_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
# Large batch tests with cache disabled
-NORMAL_BATCH_NO_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with disabled cache
-NORMAL_BATCH_NO_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with disabled cache
+NORMAL_BATCH_NO_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
+NORMAL_BATCH_NO_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
# Large batch tests with no wait and with cache configuration
-NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with no_wait config
-NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with no_wait config
+NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
+NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings
# Single actor tests with cache disabled
# shellcheck disable=SC2034
-NO_CACHE_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 5000 tcp "1_producer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with forced cache
+NO_CACHE_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 5000 tcp "1_producer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with default storage settings
# shellcheck disable=SC2034
-NO_CACHE_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 5000 tcp "1_consumer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with forced cache
+NO_CACHE_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 5000 tcp "1_consumer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with default storage settings
# Consumer group tests with cache enabled
BALANCED_ONLY_CACHE_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "balanced-producer" 1 8 1000 1000 1000 tcp "only_cache" "$IDENTIFIER") # Balanced producer benchmark
BALANCED_ONLY_CACHE_CONSUMER_GROUP=$(construct_bench_command "$IGGY_BENCH_CMD" "balanced-consumer-group" 1 8 1000 1000 1000 tcp "only_cache" "$IDENTIFIER") # Consumer group benchmark
# Single actor tests with cache disabled and rate limit 100 MB/s
-NO_CACHE_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache
-NO_CACHE_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache
+NO_CACHE_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings
+NO_CACHE_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings
# Single actor tests with cache disabled, fsync enabled and rate limit 100 MB/s
-NO_CACHE_FSYNC_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache
-NO_CACHE_FSYNC_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache
+NO_CACHE_FSYNC_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings
+NO_CACHE_FSYNC_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings
###############################
# Single benchmarks #
diff --git a/scripts/performance/utils.sh b/scripts/performance/utils.sh
index f710b5b..8967e9e 100755
--- a/scripts/performance/utils.sh
+++ b/scripts/performance/utils.sh
@@ -148,12 +148,11 @@
;;
esac
- # fsync is a per-topic option now, not server config, so the fsync variants
- # have to ask for it on the bench command line rather than via server env.
- local fsync_arg=""
+ # The persisted variants select the topic policy at creation.
+ local durability_arg=""
case "$remark" in
*"no_cache_fsync"*)
- fsync_arg="--enforce-fsync"
+ durability_arg="--durability persisted"
;;
esac
@@ -168,7 +167,7 @@
exit 1
}
- echo "$bench_command ${rate_limit:+ --rate-limit ${rate_limit}} ${fsync_arg} \
+ echo "$bench_command ${rate_limit:+ --rate-limit ${rate_limit}} ${durability_arg} \
--message-size ${message_size} \
--messages-per-batch ${messages_per_batch} \
--message-batches ${message_batches} \