This is an automated email from the ASF dual-hosted git repository.
kparisa pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iggy-website.git
The following commit(s) were added to refs/heads/main by this push:
new 9224964e0 blog: clustering part 3 (#141)
9224964e0 is described below
commit 9224964e064e8d5c76ea7b9a912284c8b5a4dc3c
Author: Krishna Vishal <[email protected]>
AuthorDate: Wed Oct 21 17:03:35 2026 +0530
blog: clustering part 3 (#141)
Add the third post in the Viewstamped Replication clustering series. It
covers the deterministic simulator that tests the cluster.
---
content/blog/vsr-clustering-part-3.mdx | 232 +++++++++++++++++++++++++++++++++
1 file changed, 232 insertions(+)
diff --git a/content/blog/vsr-clustering-part-3.mdx
b/content/blog/vsr-clustering-part-3.mdx
new file mode 100644
index 000000000..134f6d52e
--- /dev/null
+++ b/content/blog/vsr-clustering-part-3.mdx
@@ -0,0 +1,232 @@
+---
+title: Apache Iggy's migration journey to clustering powered by Viewstamped
Replication (part 3)
+author: krishna
+tags: ["engineering", "clustering", "viewstamped-replication", "consensus",
"rust", "simulation", "testing"]
+date: 2026-10-27
+---
+
+## Introduction
+
+For about three months, from February to May 2026, a simulator was the only
thing that could run Iggy's clustering code. We built the `consensus`,
`metadata`, `journal` and `message_bus` crates next to the existing server,
which kept shipping releases, and it was May before `iggy-server-ng` could boot
a replica. The simulator runs a whole cluster on one thread, ten milliseconds
at a time, and draws every packet delay, crash and task switch from a single
64-bit seed. We still test the clu [...]
+
+[Part
1](https://iggy.apache.org/blogs/2026/09/28/vsr-clustering-part-1/#why-viewstamped-replication)
gave determinism as one reason we chose Viewstamped Replication: the primary
of view `v` is replica `v % n`, so replicas that receive the same messages in
the same order make the same decisions. That only helps if the clock, the
network, the disk and the order async tasks run in are deterministic too, and
most of the simulator's code is there to make them so.
+
+## What the simulator runs
+
+Every replica in the simulator is a real `shard::IggyShard` running the real
message pump, with one `VsrConsensus` group for metadata and one per partition,
plus the metadata state machine and the partitions themselves. With `--shell`,
client requests also go through the server's own request handling, login and
session binding included. Everything that code touches on its way to the
outside world is replaced:
+
+| Layer | In the simulator |
+| --- | --- |
+| Clock | `SimClock`, virtual time |
+| Journal | `SimJournal` in memory, held by the simulator so it outlives a
crash |
+| Superblock | `SimSuperblock` in memory, which a test can make fail, or stall
the way an fsync would |
+| Bus and network | one `SimOutbox` per replica, drained into a
`PacketSimulator` |
+| Async runtime | `DetExecutor`, a single-threaded executor scheduled from the
seed |
+
+The seam is the `MessageBus` trait, which carries the three calls that would
otherwise reach the real world: `sleep`, `spawn` and `realtime_micros`. By
default they're compio's timer, compio's spawn and the system clock, and the
simulator overrides all three. The comment on `sleep` explains the choice:
+
+> Routed through the bus because the bus is the one dependency the shard
already injects per environment; a separate clock generic would thread a second
seam through every shard constructor for the same effect.
+
+The login path checks a personal access token's expiry against
`realtime_micros`. If it read the system clock instead, the reply a client gets
would depend on the day the test ran. Consensus stamps prepares from an
injected clock for the same reason, and the simulated clock starts at
2026-01-01 so those timestamps don't decode as 1970.
+
+One call to `Simulator::step()` is one tick of virtual time:
+
+```text
+1. advance time by 10 ms every pump's tick fires: heartbeats, retransmits,
view changes
+ run the pumps poll ready tasks until none is left
+2. deliver packets every packet due this tick, each to a seeded shard
of its replica
+ run the pumps routing, consensus, replies and their loopback
follow-ups
+3. drain the outboxes everything sent this tick goes into the packet
simulator
+4. advance the network partitions and clogs start or end
+```
+
+When a replica has several shards, the seed picks which one each packet lands
on. Usually that's a shard that doesn't own the packet, and the frame takes the
same router hop it would take in production. Everything sent during a tick is
on the simulated wire before network time moves, which keeps a reply from
setting off another delivery in the same tick. None of this needs Docker, a
server binary, sockets or `io_uring`. The only real files are the snapshots a
checkpointing run writes.
+
+## Why every timeout is a count of ticks
+
+Each consensus timer in Iggy holds a number of ticks, and every tick takes one
off. `TICK_INTERVAL` in `core/consensus/src/vsr_timeout.rs` is 10 ms. In
production the tick is one arm of the message pump's `select_biased!`, a
`bus.sleep(CONSENSUS_TICK_INTERVAL)`, and in the simulator the same call
returns a virtual timer that `advance_time` fires.
+
+The config is still written in durations. At boot each one is divided by 10 ms
and rounded down, with a floor of one tick:
+
+| Timer | Setting | Default | Ticks |
+| --- | --- | ---: | ---: |
+| Backup's primary-liveness window | `heartbeat_timeout` | 5 s | 500 |
+| Primary's commit broadcast | `commit_broadcast_interval` | 500 ms | 50 |
+| Prepare retransmit | `prepare_retransmit_interval` | 250 ms | 25 |
+| `StartViewChange` and `DoViewChange` resend |
`view_change_retransmit_interval` | 500 ms | 50 |
+| Giving up on a view change | `view_change_status_timeout` | 5 s | 500 |
+| Recovering replica's `RequestStartView` |
`request_start_view_retransmit_interval` | 1 s | 100 |
+| Repair retry | `repair_retry_interval` | 1 s | 100 |
+
+In production a tick takes 10 ms plus whatever it had to wait for. The pump
re-arms its tick timer only after the tick's work is done, and the tick arm can
wait behind a frame the pump is still processing, a journal fsync included.
That makes 500 ticks of heartbeat window at least five seconds.
+
+Because timers count ticks, the simulator decides when time passes. A
five-second heartbeat window takes five seconds on a production cluster and 500
calls to `step()` in the simulator, each returning as soon as the pumps go
quiet. Seed 211 from part 2 now passes:
+
+```text
+$ workload-fuzz --seed 211 --ticks 4000 --plane metadata --faults none \
+ --journal-slots 80 --crash-prob 0.02 --restart-prob 0.08 --crash-primary
+workload-fuzz: seed=211 ticks=4000 clients=1 replicas=3 plane=Metadata
faults=None shell=false crash_prob=0.02 quiesce=true
+...
+ran 4000 ticks; 54 replies; crashes=11 restarts=10 still down: 1
+...
+workload-fuzz: OK (seed=211)
+```
+
+That's 40 seconds of cluster time, with eleven crashes and ten restarts, in
0.24 seconds of wall time and 32 MB of memory.
+
+The only randomness in consensus is the jitter on the prepare retransmit's
backoff. (Part 2 explained why the view-change timers aren't randomized.) Each
retry doubles the wait, up to 16 times the base, and adds a random amount up to
the same again. That PRNG is seeded from the replica and group ids, so the
jitter replays along with everything else.
+
+Code that calls compio's timers directly can't run under the simulator,
because compio panics outside its own runtime. A few storage paths still do,
and two storage tests are `#[ignore]`d because of it: `WriterLease::acquire`
drains through `compio::runtime::time::timeout`, which the deterministic
executor can't drive.
+
+## Replaying a seed
+
+`SimSeeds::derive` turns the run's seed into six child seeds, one each for the
network, the workload, the executor, the choice of entry shard, crash
scheduling and the swarm parameters. The comment on `SimSeeds` says why they
don't share a PRNG:
+
+> A shared stream couples them: adding a draw anywhere shifts every later draw
everywhere, so enabling crash injection would change the network trace, and a
bug found at one seed would vanish once an unrelated draw was added.
+
+The children are drawn from a parent PRNG in field order. An earlier version
XOR-ed a different constant into the seed for each stream, and one forgotten
constant is how the workload and the packet simulator ended up sharing a
stream. Drawing needs no constants but makes field order part of the contract,
so new streams go at the end of the struct. The workload's `Action` enum has
the same rule, with a compile-time assert on its first three discriminants.
With crash scheduling on a stream [...]
+
+We ran seed 211 twice for this post, and the two outputs matched apart from
the paths of their data directories. In the test suite,
`workload_replay_is_deterministic` pins that property: it runs seed
`0xDEAD_BEEF` twice, hashes every reply header and compares the result with
`0xECC6_9F76_2D47_8F12`. The constant moves whenever the draw order does, for
instance when an op gains an outcome the workload can aim for, and the comment
above it lists every reason it's been re-locked.
+
+A seed replays only on the build that produced it. Draws go through `rand`'s
range reduction, which means a `rand` or `rand_xoshiro` upgrade can shift every
schedule, and any change that adds a draw can too. When a seed matters, we turn
it into a test that asserts the property it broke. That's how seeds 211 and 144
ended up in the suite.
+
+## A seeded scheduler for async tasks
+
+The server is async Rust on compio, one thread per core. A shard runs its
message pump alongside short-lived tasks for poll I/O, client requests and
metadata submits, and bugs live in how those interleave. Much of our simulator
is modelled on TigerBeetle's VOPR, but TigerBeetle's replicas don't run async
tasks, so the VOPR has nothing to schedule at this level. Ours do, and we wrote
`DetExecutor` for them.
+
+`DetExecutor` keeps `!Send` futures on one thread and switches between them
only at their await points. When several tasks are ready, it polls one picked
uniformly at random from the executor's stream. Only `advance_time` moves its
clock. Timers due at the same moment fire in the order they were set, and a
sleep's deadline is fixed when the sleep is created, as in compio. The pump
relies on that, since it builds its tick timer once and polls it many times.
Every poll, timer fire and spaw [...]
+
+A crash drops the replica's pump and every task its bus spawned where they
stand, mid-await, without draining anything, and the packet simulator discards
whatever is addressed to the replica. A restart builds new shards on the
superblock and metadata WAL the simulator kept, under a new incarnation number
derived from the seed.
+
+Each round of polling has a budget of 100,000 polls. A task that keeps waking
itself runs it out, and the run fails with the seed and the schedule hash.
Whenever no task is ready, every live pump must also have an empty inbox. A
frame still sitting in one means something queued it without waking the pump, a
lost wakeup. In production the next tick would wake the pump and drain the
frame, hiding the bug, so the simulator fails the run before that tick comes.
+
+The partitions live in an `UnsafeCell<Vec<_>>`, so the borrow checker can't
see a task holding a reference into it across an `.await` while another task
reallocates the vector. #3557 removed the places where that happened. A
debug-only guard now counts live partition borrows, and inserting or removing a
partition asserts that none is held. Under `DetExecutor` a task parked on an
await stays suspended while other tasks run, which
`shell_detects_partition_borrow_held_across_await` relies o [...]
+
+Most of the determinism work was finding code that read the outside world
without saying so. Plain `futures::select!` takes its arm order from a
thread-local PRNG the simulator can't seed, so the pump uses `select_biased!`.
The partitions' namespace index is a `BTreeMap`: the pump walks it on every
tick, and a `HashMap`'s iteration order changes with each process's
`RandomState`. Argon2 password hashes take a random salt. Under the `simulator`
feature they use a fixed one, which keeps re [...]
+
+Repair and state-transfer sessions still take their nonces from
`Uuid::new_v4()`, which reads OS randomness, at five places in
`core/shard/src/lib.rs`. Nonces are only compared for equality, so the schedule
and every decision still replay, though the bytes on the wire differ between
runs.
+
+## Faults and traffic
+
+The network is a `PacketSimulator`, ported with few changes. Each pair of
processes has a link with a bounded queue, 64 packets by default, and each
packet's delay is drawn from an exponential distribution with a floor. Loss and
duplication are decided when a packet comes due. A full link evicts a random
packet to make room. On each tick any path can clog for an exponentially
distributed number of ticks, and the network as a whole can split. A partition
is symmetric or asymmetric and tak [...]
+
+`--faults` picks a profile. `light` and `heavy` are fixed, and `swarm` draws
every parameter from the seed's swarm stream:
+
+| | `light` | `heavy` | `swarm` draws |
+| --- | ---: | ---: | --- |
+| Packet loss | 2% | 10% | 0 to 15% |
+| Replay | 1% | 3% | 0 to 5% |
+| Mean delay, ticks | 3 | 8 | the minimum (1 to 3) up to 10 |
+| Link capacity | 64 | 64 | 8 to 64 |
+| Partition starts, per tick | 0.5% | 2% | 0 to 3% |
+| Partition heals, per tick | 5% | 2% | 1 to 10% |
+| Partition shape | one replica, asymmetric | random size, asymmetric | any
shape, either symmetry |
+| Clogs, per path per tick | 0.2% | 1% | 0 to 1.5% |
+| Mean clog, ticks | 10 | 25 | 0 to 40 |
+
+Faults cost progress, because every lost frame waits out a resend timeout.
With one client on one partition, a three-replica cluster drains about 440
replies in 5,000 ticks on a perfect network, 240 under `light` and 40 under
`heavy`.
+
+As the README puts it, `none`, `light` and `heavy` are three fixed points, "so
a thousand seeds against `heavy` is the same network a thousand times". `swarm`
draws a new network for each seed, as the VOPR does. Its heal probability never
draws zero, since a partition that can't heal leaves a permanently split
cluster and a liveness failure that says nothing. Its link capacity never draws
below 8, because a tiny queue drops packets by eviction and compounds with the
loss draw into a netw [...]
+
+```text
+network: loss=0.02 replay=0 delay=2..9 partition=UniformSize/Asymmetric
p_partition=0.01 p_unpartition=0.01 stability=27/54 clog=0.011 clog_ticks=31
link_capacity=57
+```
+
+Crashes come from a separate injector. On each tick, with probability
`--crash-prob`, it crashes one replica that's been up for at least 500 ticks,
as long as a commit quorum stays live. With probability `--restart-prob` it
restarts one that's been down for at least 300. The 500-tick wait gives a
rejoined replica time to catch up before it can fail again, and the 300-tick
wait lets the survivors commit past the crashed replica's log, which leaves its
rejoin something to repair. Primaries [...]
+
+The traffic is a workload of 25 actions, from `CreateStream` and
`SendMessages` to `ChangePassword` and `StoreStaleOwnerOffset`. Each action has
a module exposing the same seven items, and a macro dispatches over them, so an
action without a module doesn't compile. A shadow model predicts what each
committed op does to the metadata. The workload picks an outcome first and then
asks the shadow for an input that should produce it. `CreateStream` can aim for
`Ok` with a fresh name from the [...]
+
+A client keeps one request in flight and resends it after 200 ticks. That's
four commit broadcasts, long enough that a request only waiting for the next
broadcast doesn't get resent. A resend reuses the original bytes, because the
client table deduplicates on the request number and a rebuilt request would
draw a new number and commit a second time. Each resend goes to the next
replica in turn, which is how a client whose primary died finds the new one.
+
+## How a run fails
+
+Checks run on every tick against each live replica and group, so a run fails
on the tick where something goes wrong. Commit offsets and views may never move
backwards, and no shard may drop a frame as unroutable or misrouted. A
replica's commit walk can't sit behind a hole for 2,000 ticks, and a metadata
primary can't sit behind its own recovery barrier that long either. The checks
only read state and draw nothing from any PRNG, so turning them on doesn't
change a run.
+
+The state checker, a port of the VOPR's, compares replicas with each other,
because agreement between replicas is what consensus is for. It keeps a
canonical chain of committed metadata ops and checks every replica against it
in both directions of `(commit_a == commit_b) == (checksum_a == checksum_b)`.
The same op must hold the same prepare, and the same prepare must sit at the
same op, which catches one prepare committed at two positions. Each committed
header's `parent` must also equal [...]
+
+When the run ends, the driver drains every outstanding request, waits for the
cluster to settle on one view and runs the oracle. The oracle checks that no
live replica is ahead of the leader and that replicas agree on every committed
op they share. Heads may differ, because a replica that missed the last commit
broadcast is allowed to trail. With a single client, it also checks the
metadata committed on the leader against the shadow's prediction. Since #4191
the drain runs the per-tick c [...]
+
+A run can also pass without testing anything. The comment above the replay
baseline records that the consumer-offset ops once drew their consumer kind as
a boolean. Kind 0 isn't a valid wire value, and every request that drew it was
dropped before the server could parse it. The oracle reports what it compared
(`ops_compared`, `replicas_compared`, `namespaces_checked` and a few more)
because, as the README says, "a check that ran against an empty chain passes
silently". `--min-commits` an [...]
+
+While reviewing #4506, two reviewers changed `matches_consumer_owner`, the
check that an offset write comes from the consumer group's current owner, to
accept everything. Twenty-four fuzz seeds produced logs byte-identical to the
unchanged build, and all 228 simulator tests still passed with
`owners_compared=0`. The workload had never sent a store from a stale owner.
Now it has `StoreStaleOwnerOffset`, and a lifecycle run under `--shell` that
sees no owner-fence refusal exits 3.
+
+## Crashing the disk at every step
+
+The cluster simulator's storage never loses or tears a write. Its journal sits
on `MemStorage`, and the simulator carries partition data across a restart.
Disk faults have a second harness, `SimStorage` in
`core/simulator/src/storage.rs`, which models the parts of a Linux filesystem
that durability depends on. Partition code reaches the disk through the
`DurableStorage` and `DurableFile` traits in `core/journal`, implemented by
`DiskStorage` in production and by `SimStorage` here.
+
+Every file has buffered and stable bytes, and every directory has buffered and
stable entries. A process crash keeps the page cache, while power loss throws
away whatever isn't stable. File sync and directory sync are separate
operations. Background writeback can flush pages in any order, so a later page
can reach the disk while an earlier one is lost. When writeback fails, the
dirty pages are dropped and the error is reported once to each handle that was
open at the time. A handle opene [...]
+
+A sweep records the operations a piece of partition code performs, then
crashes at every point in that trace. At each point it fails the operation
before or after it takes effect or tears it, crashes the process or cuts the
power, and runs with and without background writeback. After each crash it runs
the real recovery code, such as `PartitionPrepareJournal::open_with_storage`,
and checks what it recovered. Seven sweeps cover 4,430 cases in 0.29 seconds:
+
+| Sweep | Cases |
+| --- | ---: |
+| Partition WAL initialization | 108 |
+| Sealed tail recovery | 224 |
+| Referenced partition WAL | 420 |
+| Partition rollback | 492 |
+| Partition transfer | 618 |
+| Partition WAL | 1,044 |
+| Owned segment | 1,524 |
+
+The partition WAL these sweeps test is the one that discarded 81 MB at boot in
part 2. Grzegorz's #4132 added tests for durability failures that nothing else
could reach, and two of them failed: a fresh handle's `fsync` reported success
after a failed writeback (#4141), and large appends weren't grouped under one
sync barrier (#4142). Issue #4252 then showed that the install backup from part
2's state transfer could miss a writeback error too.
[@jiengup](https://github.com/jiengup) fixed [...]
+
+## What it found
+
+| Bug | Kind | How it showed up | Fix |
+| --- | --- | --- | --- |
+| A rejoining metadata backup checked prepares against its sequencer instead
of its journal head | liveness | a client that was never answered | #3942 |
+| A satisfied metadata repair session was never cleared | liveness | two of
three replicas live and no progress | #4008, #4073 |
+| A replica with a hole in its committed prefix could win an election | safety
| 8 of the first 220 seeds | #4073 |
+| A view change waited on a dead replica over a write that was provably dead |
liveness | seed 211 | #4109 |
+| A committed consumer-offset delete fenced its own partition | availability |
seed 28, as a lost wakeup | #4063 |
+| A replica that joined a view through a probe answer kept an op the view had
dropped | safety | seed 144, network faults only | #4191 |
+| Writeback errors hidden from a fresh handle and from an install backup, and
appends missing group commit | durability | the storage sweeps | #4253, #4278,
#4202 |
+
+The PR that first ran the workload under network faults and crashes, #3942,
fixed the sequencer bug along the way. A backup that rejoined adopted the new
view's head before the bodies arrived, then refused the prepare that would have
filled its log, because it checked prepares against its sequencer. The op never
reached a quorum, and its client was never answered.
+
+Grzegorz found the repair-session bug while reviewing that PR. A metadata
repair session that had received everything it asked for was never cleared, and
no new session could start while it existed, so the primary kept retransmitting
the one op the backup wouldn't take. The review summed it up as "2/3 live, zero
progress", on a configuration that was "red on roughly a third of seeds". The
same review cycle turned up the hole in the committed prefix, and the sweeps
that verified those fix [...]
+
+In seed 28, replica 1 committed a `DeleteConsumerOffset` for an offset it
didn't hold, read the failed apply as divergence and fenced the partition,
which stopped its pump. The simulator still counted the replica as live, so
frames piled up in an inbox nothing was draining, and one log line later the
lost-wakeup check fired:
+
+```text
+lost wakeup: replica 1 shard 0 inbox holds 1 frame(s) at quiescence
+```
+
+Which replicas hold an offset is deliberately not agreed, so a committed
delete can't require the offset to exist. Deleting a missing offset is a no-op
now.
+
+Seed 144 diverged under network faults alone:
+
+```text
+assertion `left == right` failed: replicas disagree on committed op 7:
canonical
+checksum 0x956f651ddd80d3a6 (committed by {1, 2}) vs replica 0's
0x68d5380d7a7f4869.
+Two replicas committed different history at the same log position (seed=0x90)
+```
+
+Replica 0 led view 0, prepared up to op 7 and got no acknowledgements. Replica
1 won view 1, its merge correctly dropped op 7, and it prepared a different op
7. Replica 0 then joined view 1 through the answer to its probe, still holding
its own op 7, and its commit walk applied it. The probe's `StartView` carried
no headers to compare against. It does now, and #4191 also fixed two more
defects at the same spot.
+
+Reviewers run campaigns against branches now. Grzegorz's review of #4506,
which routes partition requests to their primaries, found four bugs before it
merged. Seed 7016 lost a committed partition op, and the state checker named
it: `replica 2 and witnesses {0: 6} disagree on committed partition op 93`. The
first fix for that let a fenced backup commit an op its view had discarded, and
divergence went from 6 of 300 seeds to 17. Seed 7002 built a repair request for
the empty range 38 to 3 [...]
+
+[@almostintuitive](https://github.com/almostintuitive) filed two issues with
simulator reproductions. In #4284 a partition replica commits its own stale
prepare after rejoining a newer view, and in #4294 a metadata primary panics
after its stall detector truncates the pipeline head. Fixes for both are in
review, #4433 and #4429.
+
+## What it can't see yet
+
+No campaign runs in CI. CI runs the crate's 244 tests, and `workload-fuzz` is
run by hand. TigerBeetle runs its VOPR around the clock on a fleet of machines.
A nightly sweep is first on our list.
+
+The cluster simulator doesn't run the production metadata WAL or superblock.
`SimJournal` keeps its index in a `HashMap`, under a `TODO: Replace with actual
Journal`, and `SimSuperblock` has none of the real one's framing or checksums.
The storage sweeps run the real WAL, but on a single node, so a simulated
cluster never loses an unpublished tail at boot the way part 2's node did.
+
+Nothing corrupts a packet in transit. Every replica reads the same perfect
clock, so clock skew goes untested. `workload-fuzz` always builds one shard per
replica, which leaves partitions spread across shards to the library tests, and
swarm draws only the network: the replica count, the clients and the crash
rates are whatever the command line says.
+
+Seeds still fail on master. For this post we ran 14 profiles against seeds 1
to 100, 1,400 runs in 71 seconds on a 16-core machine. None diverged. Of the 49
that failed, 27 tripped the recovery-barrier check and 22 never drained, 15 of
those with a lifecycle op such as `DeleteTopic` waiting on a partition group
that hadn't finished recovering. Another 80 exited 3, 79 of them on the
lifecycle profile, which needs more than our 8,000 ticks to see an owner-fence
refusal.
+
+When a run misses its request target, the VOPR heals a random core of
replicas, at least a quorum, and requires that core to recover. Our
`--heal-before-quiesce` heals everything, so a wedge it happens to resolve
never gets reported. A core-healing mode comes after the nightly sweep, then
the production journal and superblock running over `SimStorage` inside the
cluster, then packet corruption.
+
+## Closing words
+
+This is the last of the three parts. The simulator lives in `core/simulator`,
and its README covers every flag. Here's a run under swarm faults with crashes:
+
+```sh
+cargo run --release -p simulator --bin workload-fuzz -- \
+ --plane uniform --faults swarm --crash-prob 0.01 --restart-prob 0.05 --ticks
6000
+```
+
+Leave out `--seed` and it draws one and prints it. A failing run prints
`workload-fuzz FAILED, reproduce with --seed N` ahead of the panic, and an
issue with that line and the flags is the most useful bug report we get. Thanks
to [@almostintuitive](https://github.com/almostintuitive) for the two issues
above, and to [@jiengup](https://github.com/jiengup) for the storage fixes.