Verification
Parsley's primary correctness gate is a deterministic simulator with a ground-truth oracle,
in the test tree of the parsley package. Broker integration tests exercise plumbing; the
simulator exercises the protocol, because only a simulator controls interleavings, injects
crashes at chosen points, and knows the real causal history to judge deliveries against.
The simulator and oracle also ship, as the
conformance kit — the test-jar artifact of the library's
coordinates — so a vendored copy or an alternative host can run this same verification
outside this repository.
The simulated world
SimBroker models partitions with real offset occupancy: business records, transaction
markers, and aborted records all consume offsets, and a read_committed fetch returns only
business records — so the density adaptation is exercised on essentially every run.
Transactions are step-atomic: one simulation step is one fetch-process-commit cycle, appending
the step's records and markers together, which models EOS faithfully at the granularity that
matters (consumers see marker/abort holes; no transaction spans an observation point).
SimNode hosts the real CausalNode exactly as the Streams adapter does: staged store
mutations, staged consumer positions, sends stamped at the single site, and a commit-or-abort
at step end. Nothing hands a send's offset back to the node that made it, because the adapter
does not fold producer acknowledgements either. A crash aborts
the in-flight step — appended records become aborted records at their offsets, staged state
discards, and the node later restarts from committed state through the full init path
(restore, end-offset seed, rescope). EdgeProducer scripts plain clients: sequential
producers whose sends claim their previous acknowledgements, and observers that fold consumed
records before producing.
The scheduler draws from every enabled action — fetch, position advance, tick emission, producer op, crash, restart — with a seeded generator, so every run is a reproducible interleaving and every seed a different one.
Reproducibility is a property the protocol has to cooperate with, not one the scheduler can
supply alone. NodeConfig normalises its channel sets with Set.copyOf, and the JDK's
immutable sets randomise iteration order per JVM; iterating one directly would make the
cascade's cross-channel delivery order vary between runs, and the host runs user logic per
delivery in list order, so that order reaches the broker as send order and from there into
every offset downstream. CausalNode therefore iterates a sorted copy wherever set order can
escape — the cascade, rescope, and the scope record — and CausalNodeDirectedTest pins
both. Without it a seed does not determine its run, and PIT verdicts drift between runs of an
unchanged tree.
The oracle
The oracle never reads a vector clock. It tracks real happened-before ancestry: each business emission records the emitting actor's causal past (everything delivered there plus its own prior sends, transitively closed), staged and rolled back in mirror with the node's transaction so an aborted step teaches the oracle nothing. On every delivery it checks:
- Causal order — every ancestor on a consumed channel at or above the node's baseline was delivered there first.
- Per-channel FIFO — delivered offsets strictly increase.
- No duplicates — a record is delivered at most once per node (committed).
At drain it checks completeness: every business record above baseline on a consumed channel was delivered. A world that cannot drain within its step budget fails outright — that is the wedge and livelock detector.
The oracle judges deliveries by coordinate, never by content: a delivery is the record at
(channel, offset), and what the protocol hands the user processor at that coordinate is
outside its verdict. Nothing the simulator does can catch a restore that returns the right
record with the wrong bytes, which is why the payload of a held record is pinned by directed
assertions instead (V10).
Obligations
| # | Obligation | Enforced by |
|---|---|---|
| V1 | Causal delivery under random interleavings | Oracle, on every delivery |
| V2 | Per-channel FIFO, no loss, no duplicates | Oracle sequence checks + completeness |
| V3 | Crash/restart preserves V1–V2 | Crash-injecting runs, both idle and mid-transaction |
| V4 | Markers and aborts never wedge the frontier | Marker-dense logs on every run; filter scenarios |
| V5 | Liveness: every world drains, queues empty | Drain budget + completeness at drain |
| V6 | Cycles drain; trailing markers resolve | Damped-feedback and filter scenarios |
| V7 | Truncation below a true stability bound is invisible | Truncate-then-continue scenarios |
| V8 | The oracle catches a broken protocol | OracleSelfTest: an eager, dependency-ignoring protocol must be flagged |
| V9 | The kit catches a broken host | HostContractSelfTest: a host that breaks step-atomicity, or withholds the position-advance signal, must be flagged |
| V10 | A restored record is the record that was fetched | CausalNodePersistenceTest: key bytes, value bytes, and timestamp survive the hold-queue round trip — the one fact about a delivery the oracle does not judge |
| V11 | The guards fail closed rather than guess | CausalNodeFailClosedTest, one construction per guard |
V8 is the keystone: it runs a deliberately broken implementation and requires the oracle to
flag it. A harness that cannot fail is not a harness; if V8 ever breaks, every green result is
meaningless. V9 is its host-side analogue: SimNode can shirk one host duty at a time
(HostFault), and the suite requires the shirked duty to be caught — which is what makes the
harness meaningful as a conformance kit for hosts other
than the shipped adapter.
Anti-vacuity
Every scenario asserts that the machinery it targets actually fired: records were held at the gate, crashes were injected, position advances ran, a crash landed while records were held. A race that never occurs proves nothing, so the suite refuses to pass on quiet schedules. Indicatively, the widest scenario across its hundred seeds drives on the order of ten thousand scheduler steps, five thousand oracle-checked deliveries, hundreds of crash injections, and a thousand-plus position advances.
These assertions count seeds, not events. A sum over a hundred seeds passes when one seed
happened to be interesting, which is the vacuity they exist to refuse; the floor is a minimum
number of seeds that reached the state (SEED_FLOOR, currently twenty against measured rates
of forty to seventy). The distinction is not academic — the sums were what hid the fact that
the hold-queue restore path ran on five node incarnations out of nearly seven thousand.
Two interleavings the scheduler will not reach on its own are bought explicitly, because an unweighted draw spends its budget before the interesting state exists:
- A crash while records are held, the only route into the hold-queue restore path. The last crash of a world's budget is reserved for a node that is actually holding, and a holding node's crash actions are drawn at double weight. Every interleaving an unweighted draw could produce stays reachable.
- A consumer whose baseline sits above a channel's earlier history, which is what makes a stamp's claims load-bearing rather than redundant with per-channel FIFO. Scenarios that target a particular stamp term retire the earlier history with retention, or join the consumer after it, so the term under test is the only thing that can order the delivery.
Scenario coverage
Ticking stages (the tick channel is both consumed and produced, so a tick is stamped, lands on the emitting task's own partition, and returns through the gate carrying the node's causal past — including under crash injection, and across a restart that drops one of the stage's other sinks while the tick self-loop still holds claims on it), shared-input races (a stage's output racing its input at a shared consumer), transitive claims through a hop that does not consume the origin, edge-producer cross-topic ordering, observed causality from a plain consumer, crash-recovery chains, filter stages with quiet sinks and trailing markers, multi-partition sink ordering through sequence claims, which a node's own sends are always claimed in until a restart's end-offset seed converts them to offsets — the simulator never delivers a producer acknowledgement, because neither the protocol nor the Streams adapter folds one. The late-joiner sequence-claim wedge probe, damped feedback cycles (with a consumer of both sides of the loop, since the loop's own nodes cannot gate each other), truncation mid-history, log-start stability (full-retention truncation emptying the stamp-side clocks, and a from-earliest joiner arriving after truncation), scope shrink across a restart, a restart's own prior-incarnation sends ordered against each other at a consumer of both sinks, and the wide soak combining most of the above.
The scenarios covering the stamp's four terms are each built so that the term is what orders the delivery: the shrink scenario retires the dropped input's channel history from t2 before its consumer joins (leaving carried ancestry as the only claim on the t1b past), and the restart scenario joins its consumer after the restart (so the two sinks race, and only the end-offset seed relates them). Both were rebuilt after the terms were found to be removable with the suite green — the earlier shapes let per-channel FIFO supply the ordering for free.
Mutation testing
PIT runs over the whole package inside mvn verify, serially, with a 75% mutation floor and
an 80% line-coverage floor — both well below the standing scores (681 of 738 mutations
killed, 92%; line coverage 1345/1465, 92%; test strength 95%) so refactoring noise does not
flake the build.
The gate runs on one thread deliberately. The only timing-sensitive verdict PIT produces is
TIMED_OUT, which scores as a kill, so a threaded run under CPU contention can inflate the
score. One thread removes that hazard rather than periodically re-checking for it, and costs
little: 506s serial against 402s at four threads. Verdicts are reproducible — five
consecutive runs (four threaded, one serial) returned identical verdicts for all 738 mutants,
compared as a multiset. That reproducibility depends on the iteration-order guarantee
described under the simulated world; before it, runs of an unchanged
tree disagreed.
The delivery gate itself has no surviving mutants. All eight mutants of deliverable,
the predicate every guarantee rests on, are killed.
The eight TIMED_OUT mutants are all structural non-termination rather than slow tests: six
in the cascade's fixpoint, plus one each in advanceFrontier and frontierOf. Each stops the
frontier advancing while leaving the loop's changed flag set, so the fixpoint never settles.
They are the same eight on every run.
Of the 33 survivors, 20 sit in CausalNode, and every one carries a written equivalence
argument rather than an assumption:
- Redundancy-masked paths, the largest group. The density adaptation reaches the same frontier fold from two sites — a receive-time bridge and the cascade's own trailing-run fold, both fed by the same recorded position — so removing either alone is invisible while the other stands. These are not gaps in the scenarios; they are places where the protocol is deliberately belt-and-braces, and a mutation that removes one brace changes nothing.
- Stamp normalisation of a node's own echoed claims, provably equivalent:
prepareSendmerges bothownOutputsand carried ancestry into the stamp beforenormalizeruns, and the upgrade target is drawn from exactly those two clocks, so upgrading a self-claim to offset space can never add anything the stamp does not already carry. Normalisation of another sender's claim is not equivalent, and is killed. - Equivalent mutants, accepted: the length guards in the held-record envelope
(
if (keyLen > 0) put(key)versus>= 0— writing a zero-length array is a no-op), the half-open bound of the hold-queue restore window (the store never holds an entry attailIndex), and mutants whose only effect is degrading an optimisation the protocol does not rely on for safety.
The remaining 13 survivors sit outside the protocol, in the adapter and the probes, and need a
seam that does not exist: a started runtime, a log appender, a multi-partition sink
TopologyTestDriver cannot provide, or a task migration.
Uncovered mutants (24) are led by the Admin-backed seam (AdminBrokerOffsets and the admin
resolver in TopicIds, 18 between them), which runs only against a real cluster and so falls
outside PIT's measurement: the seam stays thin, the logic behind it is driven through
BrokerOffsets by the simulator, and the broker smoke suite exercises it against a live
broker. The remaining six are CausalStreams and Parsley members reachable only from a
started runtime, and one branch each in Stage$Adapter, ContractProbes, and
KafkaStateStore. None is in the protocol core.
Two of the surviving mutants are findings about src/main rather than about the tests, and
are recorded as such: the tick-topic branch of ContractProbes.runTopology cannot produce a
finding under any input, and the 0xFF carry loop in KafkaStateStore.forEachPrefix is
unreachable because store keys are UTF-8 strings and UTF-8 never emits that byte.
The broker smoke suite
The broker-it Maven profile runs the *IT test classes under failsafe against a real
single-node broker in a Docker container (Testcontainers), pinned to the minimum supported
broker version so the stated floor is the one actually tested. It carries the three
obligations only a live cluster can discharge:
- The broker side of the seam contract.
AdminBrokerOffsetsITasserts that a real cluster answers the twoBrokerOffsetsqueries as the protocol assumes: end offsets per partition with empty partitions included, log starts advanced by record deletion, and definitive absence by topic ID that survives recreating a topic under the same name. - The assembled runtime on a live cluster.
ParsleyStreamsITrunsParsley.streamsend to end under exactly-once: records flow from source to sink carrying parseable clock, sender, and sequence headers on the wire, and a restart of the same application on the same state directory resumes without loss or duplication. - A task migrating between two live instances.
ParsleyStreamsITruns two instances of one application over a two-partition source and drives a task across two rebalances — one when the second instance joins, one when the first stops cleanly. Each partition's records arrive exactly once in produced order, and each task's stamps carry the same sender identity on its new host as on its old, since identity is derived from the application id, the stage name, and the partition rather than from the incarnation. The rebalances are asserted rather than assumed: each phase waits on the instances' reported active-task counts, so a run that silently never migrated fails at the wait instead of passing as a single-instance run.
The suite is a smoke test of plumbing, not a correctness gate: a real broker offers no
control over interleavings, so every causal-order obligation stays with the simulator. It
sits outside the default mvn clean verify gate (it needs Docker) and outside PIT's
measurement, and CI runs it as a separate workflow whose failure does not block a snapshot
publish.
What the simulator does not cover
Real broker behaviour outside the model: rebalances, consumer-group protocol edge cases, and
timing that depends on actual I/O. The broker smoke suite covers the seam contract, the
assembled plumbing, and a clean task migration on a live cluster; TopologyTestDriver smoke
tests cover the adapter's plumbing (hold-and-release, stamp content, fail-closed corrupt
headers, the buffered-batch position guard) without a broker.
Task migration is covered only in its benign shape: a task moving between two live instances
across a clean join and a clean stop, with state small enough that the joining instance is
inside acceptable.recovery.lag and takes the task on the first rebalance. Migration away
from an instance that dies mid-transaction, standby warm-up and the probing rebalances that
move a task with large state, and migration under sustained load all remain uncovered.
Two further boundaries are worth naming, because a scenario list reads as a coverage claim:
- Ticks are modelled at the protocol level, not the adapter's. The simulator drives the
self-loop that matters — stamp, own-partition append, return through the gate — with the
scheduler standing in for the wall clock, which is strictly more adversarial than a real
timer. The punctuator scheduling, the partition-encoded key, and the sink partitioner stay
TopologyTestDriver-only, as plumbing rather than protocol. - Some shapes are pinned by directed tests rather than reached by random schedules. A
skipped run behind a held head needs a claim naming an offset inside that specific gap; the
randomized topologies reach it only by coincidence, so
CausalNodeDirectedTestconstructs it deterministically instead. The drain check would catch a regression in any world that happens to build the shape, but the directed test is what guarantees it is built at all.