Skip to content

feat(partitions): persist log and index concurrently, harden recovery - #3970

Merged
spetz merged 9 commits into
masterfrom
parallel-log-index-fsync
Aug 30, 2026
Merged

feat(partitions): persist log and index concurrently, harden recovery#3970
spetz merged 9 commits into
masterfrom
parallel-log-index-fsync

Conversation

@hubcio

@hubcio hubcio commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Concurrent log and index fsync

Every journal flush issued two serialized fdatasyncs, log then index, so an fsync-gated topic paid two device round trips per ack. Both files now persist under one futures::future::join: 13.4k to 26.1k msg/s and p50 5.83 to 3.01 ms on ext4 with enforce_fsync and messages_required_to_save = 1, 8 producers into one partition. Cursors advance only after both saves succeed, so a failed half keeps its slot and the callers that survive rewrite the same positions rather than appending a duplicate.

Recovery had to change first

The old ordering was a barrier only under enforce_fsync = true. With the shipped default the page cache already wrote the two inodes back in any order, and recovery's own pass C truncates the log before the index, so an index holding entries the log does not back was already ordinary. Boot refused it as IndexLogDivergence, which on a single-replica node permanently tombstoned a partition whose log was healthy.

An index entry is derived from the log, so it is never evidence about the log. Recovery now drops such an index whole and rebuilds it from a byte-0 walk rather than stepping back to the highest entry the log still proves. Stepping back is unsound: that anchor can sit above interior damage, and the residue probe only scans forward from where a walk stopped, so a hole under it goes unread while end_offset still advertises the offsets over it. The byte-0 walk reads the damage and refuses with every byte preserved, and checksums every batch it accepts, since unordered files mean a durable index entry no longer implies a durable chunk.

Under enforce_fsync = true the step-back depth is measured first, because there it is evidence about the log. Flushes are serialized and each fdatasyncs the whole log file, so an entry above entry N proves the log was synced through chunk N, and only the chunk in flight at the crash can strand one. A deeper gap refuses as the new FsyncedLogLoss rather than rebuilding, which would let the partition re-mint offsets a client was already promised. The probe measuring that depth is capped, since past the cap the verdict is refusal either way.

That allowance is not a promise that nothing acked is lost. The partition journal is in memory and never replayed at boot, and enforce_fsync decides whether a flush fdatasyncs, never whether one happens, so at the default messages_required_to_save = 1024 up to 1023 acked messages sit unflushed. Only messages_required_to_save = 1 gates every ack on its own fdatasync. The refusal is unaffected: an acked-but-unflushed message writes neither log bytes nor an index entry, so it can neither manufacture a step-back nor mask one.

Called out rather than fixed: when the log does back the last index entry, the walk starts there and [0, last.position) is not re-read at boot, where at-rest damage surfaces on the read path via validate_checksum instead. Closing it would mean re-hashing every segment on every boot, since no clean-shutdown marker exists on disk.

A persist failure now stops the server, not one shard

A local commit failing for an op the cluster had already committed panicked on purpose: the replica is divergent, its commit_min never advanced, and the next advance would assert. That panic was meant to end the process and never did. It runs on the shard's message pump, and compio::runtime::spawn catches the unwind, so the process lived on with one pump dead. That pump drives every partition on its shard, so all of them stopped committing, ticking and replying while the listeners and other shards kept answering. Clients got no error either: frames to the dead shard were dropped once its inbox filled, so each request blocked until the client's own read timeout.

The partition now records the fault instead of panicking, which also fences it, so on_ack, commit_journal and the tick become no-ops. The tick reports it to the pump, which breaks, skips the queued tail drain (those frames re-enter the commit path that just failed) and still runs its final flush. The spawn site flips the shared shutdown flag so sibling shards stop gracefully, and the verdict leaves as ShardFatal, so the process exits non-zero.

Quarantining only the faulting partition was the alternative. A runtime persist failure is a device signal rather than one bad chain, and a quarantined partition cannot resume without a restart anyway.

Also in this PR

The active segment now fills the same per-segment read-state slot that sealed segments use. It was populated for sealed segments only, so a consumer following the head paid one openat per poll, each an io_uring operation prone to an io-wq punt. It is demand-filled, dropped when the segment seals, and outside the sealed LRU budget so unrelated traffic cannot evict it.

Three smaller fixes: latency extremes are backfilled only when all three of min, max and standard deviation are absent, since backfilling individually let a report mix a raw value with two derived ones. The segment writer save methods are narrowed to the crate. And both index scans yield on a stride, since each can run a megabyte-scale index with no disk read to key a yield on.

Tests and verification

The integration test SIGKILLs a node after an eager flush with its log cut at the last index entry's position, the only depth a crash can produce under enforce_fsync, then asserts the rebuild marker in the boot log, that every rebuilt entry opens inside the truncated log, the surviving prefix served, nothing fenced, and the node caught up. Unit tests cover the refusal, the probe cap, and the commit fault fencing instead of panicking.

fmt, sort and clippy clean with all features and targets. partitions 134, shard 45, server 326, and the cluster:: suite against a server binary rebuilt from the branch head, since the integration crate does not rebuild it.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Aug 26, 2026
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.90%. Comparing base (996ac04) to head (19ed2e9).

Files with missing lines Patch % Lines
core/shard/src/router.rs 61.11% 14 Missing and 7 partials ⚠️
core/server/src/server_error.rs 0.00% 7 Missing ⚠️
core/shard/src/lib.rs 50.00% 6 Missing and 1 partial ⚠️
core/partitions/src/iggy_partition.rs 98.15% 2 Missing and 3 partials ⚠️
core/server/src/bootstrap.rs 88.57% 1 Missing and 3 partials ⚠️
core/partitions/src/iggy_index_writer.rs 83.33% 1 Missing ⚠️
core/partitions/src/state_transfer.rs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3970      +/-   ##
============================================
+ Coverage     84.76%   84.90%   +0.13%     
  Complexity     1405     1405              
============================================
  Files          1224     1224              
  Lines        177971   179301    +1330     
  Branches     144285   145618    +1333     
============================================
+ Hits         150854   152232    +1378     
+ Misses        23100    23024      -76     
- Partials       4017     4045      +28     
Components Coverage Δ
Rust Core 85.78% <89.47%> (+0.15%) ⬆️
Java SDK 67.35% <ø> (ø)
C# SDK 75.39% <ø> (ø)
Python SDK 90.06% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.23% <ø> (+0.09%) ⬆️
Go SDK 69.29% <ø> (+0.03%) ⬆️
Files with missing lines Coverage Δ
core/partitions/src/iggy_index.rs 92.85% <ø> (ø)
core/partitions/src/lib.rs 0.00% <ø> (ø)
core/partitions/src/log.rs 82.06% <100.00%> (+1.19%) ⬆️
core/partitions/src/messages_writer.rs 88.97% <100.00%> (+7.29%) ⬆️
core/partitions/src/poll_plan.rs 93.01% <100.00%> (+0.21%) ⬆️
core/partitions/src/types.rs 48.95% <ø> (ø)
core/server/src/dispatch.rs 89.53% <100.00%> (+0.07%) ⬆️
core/server/src/segment_recovery.rs 91.83% <ø> (+2.87%) ⬆️
core/simulator/src/lib.rs 96.95% <100.00%> (+<0.01%) ⬆️
core/partitions/src/iggy_index_writer.rs 90.36% <83.33%> (+1.33%) ⬆️
... and 6 more

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hubcio
hubcio force-pushed the parallel-log-index-fsync branch from 6fb6cbb to 2d6081e Compare August 28, 2026 06:38
@hubcio hubcio changed the title feat(partitions): persist segment log and sparse index concurrently feat(cluster): forward misrouted writes, persist log and index together Aug 28, 2026
@hubcio
hubcio force-pushed the parallel-log-index-fsync branch 2 times, most recently from bd76371 to a1dac8b Compare August 28, 2026 15:59
@hubcio hubcio changed the title feat(cluster): forward misrouted writes, persist log and index together feat(partitions): persist segment log and sparse index concurrently Aug 28, 2026
Comment thread core/server/src/segment_recovery.rs Outdated
Comment thread core/server/src/segment_recovery.rs Outdated
Comment thread core/partitions/src/iggy_partition.rs Outdated
@hubcio
hubcio force-pushed the parallel-log-index-fsync branch from a9b39c7 to 44230dc Compare August 29, 2026 16:53
hubcio and others added 6 commits August 29, 2026 18:53
Every journal flush issued two serialized fdatasyncs, log then index,
so an fsync-gated topic paid two device round trips per ack. Both
files now persist under one futures::future::join: 13.4k -> 26.1k
msg/s and p50 5.83 -> 3.01 ms on ext4 with enforce_fsync and
messages_required_to_save = 1, 8 producers into one partition. The
cursors advance only after both saves succeed, so a failed half keeps
its slot and the retry overwrites it; MessagesWriter::rewind lost its
last caller and is gone.

Recovery had to change first. The old order was a barrier only under
enforce_fsync = true: with the shipped default the page cache already
wrote the two inodes back in any order, and recovery's own pass C
truncates the log before the index, so an index holding entries the
log does not back was already ordinary. Boot refused it as
IndexLogDivergence, and on a single-replica node that refusal was a
permanent tombstone for a partition whose log was healthy.

An index entry is derived from the log, so it is never evidence the
log lacks. Recovery now steps back to the highest entry the log
proves and floors the index there; when no entry proves, or the
entries contradict each other (out of order, or below the segment
start), it drops the index and rebuilds from the log. Nothing a
client was promised is lost, since no ack precedes persist, and the
walk still runs its residue probe, so a survivor past damage refuses
as before. That walk now checksums every batch it accepts, because
unordered files mean a durable index entry no longer implies a
durable chunk and one chunk can hold several batches. The step-back
is async, yields on refills and charges the shared probe budget, so a
crafted tail refuses as UnverifiedResidue instead of stalling boot;
FileScanner::slice_at anchors its backward moves to keep refills
linear.

An integration test covers the shape: a three-node cluster, one node
SIGKILLed after an eager flush, its log cut behind the last index
entry, then restarted. It asserts the floor marker in that node's
stdout, the exact floored index length while the log is still exactly
the cut length, the surviving prefix served and the node caught up.
It is red before the recovery change and green with it.

Measuring this needed three benchmark fixes. Reports took min, max
and std_dev from the per-bucket moving average series, which averages
the extremes away while the percentiles beside them used the raw
samples; all three now come from those same sorted samples. The
balanced-producer kind refused fewer than two partitions, but one is
a legal target and the shape a durability benchmark needs. And
--enforce-fsync claimed to fsync every write while the topic's flush
threshold (server default 1024) still governed when the journal
reached disk, so acks were not durability-gated unless it was also 1;
that threshold is now exposed as --messages-required-to-save, plumbed
into topic creation, named in the fsync help and shown in an example.
A sealed segment clones a cached descriptor on a poll, but the active
segment had no read-state slot and reopened its log file on every single
poll. Give it a slot too, filled on the first read miss and dropped when
the segment seals.

The active descriptor stays outside the sealed LRU. That cache is keyed by
start offset and budgeted at twelve entries, so admitting the active
segment would let unrelated sealed traffic evict it. Its lifetime is bound
instead to the two sealed-ness transitions, rotation and the state-transfer
un-seal.

Only the descriptor is cached. The sparse index and the offset memo stay
sealed-only because they assume an immutable file, and the active segment
grows under the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The legacy-report backfill tested min, max and standard deviation
independently, so a run whose samples are all equal had its genuine zero
standard deviation refilled from the per-bucket moving average. That put
back the averaged-away extremes the raw-sample summary exists to avoid.

Reports written before the three fields existed receive them from serde's
default together, so all three reading zero is what identifies such a
report. Key the backfill on that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither save_indexes nor save_frozen_batches has a caller outside the crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The anchor search keyed its yield on the scan window refilling. An index
entry whose position is past the log end is rejected on arithmetic alone
and reads nothing from it, so it never triggers a refill, and an index
floored back to a short log is mostly those entries. The search therefore
held the shard core, signal handling included, for one pread per entry
across the whole file.

Yield every ANCHOR_SEARCH_YIELD_STRIDE entries as well, and lift the
refill check out of the inner loop so it is reached on every entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recovery floored an index whose last entry the log could not back:
it stepped down to the highest entry the log still proved and
anchored a walk there. That anchor can sit above interior damage,
and the residue probe only scans forward from where a walk stopped,
so a torn chunk below it went unread while end_offset kept
advertising the offsets over it.

An index the log contradicts is no longer a description of that
log, so no entry in it is a trustworthy anchor. Drop it whole and
walk from byte 0 instead, rebuilding the index from the batches the
walk proves: the walk reads the damage, sees the would-be anchor's
own batch as a survivor past it, and refuses with every byte
preserved.

Under enforce_fsync the step-back depth is measured first, because
there it is evidence about the log. Flushes are serialized and each
fdatasyncs the whole log file, so an entry above entry N proves the
log was synced through chunk N, and only the chunk in flight at the
crash can strand one. A deeper gap means the log lost acknowledged
bytes; rebuilding would re-mint offsets a client was already
promised, so it refuses as FsyncedLogLoss.

The consistency scan yields on a stride as well. It runs over the
whole index on every clean boot and compares a window of entries
per pread, so the per-read yield the walks rely on never fires and
a megabyte-scale index held the shard core for its whole length.
@hubcio
hubcio force-pushed the parallel-log-index-fsync branch from 44230dc to a48b404 Compare August 29, 2026 16:54
hubcio added 2 commits August 29, 2026 20:40
Four places asserted more than the code behind them proves.

The fsync gate's comments said persistence completes before the
client reply. It does not. The partition journal is in memory and
is never replayed at boot, and enforce_fsync decides whether a
flush fdatasyncs, never whether one happens, so at the default
messages_required_to_save = 1024 up to 1023 acked messages sit
unflushed and the one-entry rebuild can discard a chunk holding
them. Only messages_required_to_save = 1 gates acks on fsync. The
refusal itself is unaffected: an acked-but-unflushed message
writes neither log bytes nor an index entry, so it can neither
manufacture a step-back nor mask one, and the depth argument rests
on serialized flushes rather than on reply timing.

The backward anchor probe claimed a budget bounded it. That budget
bounds hashed log bytes; the step count had nothing bounding it,
one 24-byte pread per entry over an index that can run to
megabytes. Cap it at 4096 entries. Only the first entry probed can
leave the step-back inside the one-entry allowance, so past it the
refusal is already decided and deeper probes only sharpen the byte
it names. The refusal now carries the depth searched, so no
provable entries in a capped window cannot be read as the log
backing nothing.

The index consistency scan rejected a first entry below the
segment's start offset but accepted one above it, or at a
non-zero position. Every producer mints that entry at the
segment's own first batch, and the accepted path reads its
timestamp as the segment's start timestamp, so a head belonging to
another segment was not merely unused. Require it, and rebuild
from the log when it is absent.

The persist-failure comments called the panic partition-scoped. It
unwinds onto the shard message pump and takes every partition on
that shard with it. They also said a failed persist is re-read by
the next commit, which holds only for the transfer-offer flush.
A local commit failing for an op the cluster had already committed
panicked, deliberately: the replica is divergent, its commit_min
never advanced, and the next advance would assert. The panic was
meant to end the process.

It never did. The commit path runs on the shard's message pump,
and compio::runtime::spawn catches the unwind, so the process
lived on with one pump dead. That pump drives every partition on
its shard, so all of them stopped committing, ticking, heartbeating
and replying, while the listeners and the other shards kept
answering and the server looked healthy. Clients did not even get
an error: once the dead shard's inbox filled, frames were dropped
with a warning and a counter, and no reply was ever produced, so
each request blocked until the client's own read timeout. The pump
handle is only inspected during shutdown, so nothing noticed.

The partition now records the fault instead of panicking, which
also fences it: on_ack, commit_journal and the tick all turn into
no-ops, so it cannot advance while the shard winds down. The tick
reports the fault to the pump, which breaks, skips the queued
drain (those frames would re-enter the commit path that just
failed) and runs its final flush, the fenced partition included,
since its resident prefix is cluster-committed data worth writing
wherever the disk still takes it. The spawn site then flips the
shared shutdown flag, so every sibling shard's watchdog drives its
own graceful stop.

The pump's verdict travels out through the existing drain path as
ShardFatal, so the process exits non-zero. A node that stopped
because it could not persist a committed op must not look to an
orchestrator like a clean shutdown.
@hubcio hubcio changed the title feat(partitions): persist segment log and sparse index concurrently feat(partitions): persist log and index concurrently, harden recovery Aug 29, 2026
@spetz
spetz merged commit 5916e6f into master Aug 30, 2026
99 checks passed
@spetz
spetz deleted the parallel-log-index-fsync branch August 30, 2026 15:44
@github-actions github-actions Bot removed the S-waiting-on-review PR is waiting on a reviewer label Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants