RaftStorageConformanceSuite
Reusable contract test suite for RaftStorage implementations.
Subclass and implement newStorage and reopen to bind any storage under test. Every Test in this class encodes a required invariant of the RaftStorage contract — a conforming implementation must pass all of them.
Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every storage adapter can subclass it from its own test source set.
class SqliteRaftStorageConformanceTest : RaftStorageConformanceSuite() {
override fun newStorage(): RaftStorage = SqliteRaftStorage(inMemoryDb())
override suspend fun reopen(storage: RaftStorage): RaftStorage =
SqliteRaftStorage((storage as SqliteRaftStorage).closeAndReopenTheSameFile())
}newStorage must return a fresh, empty instance on every call — term 0, no vote, no established leader, empty log. reopen must return a second handle onto the medium the given instance wrote to, which is what makes the durability half of this contract checkable at all (#2301).
Faithfulness, not validation (#1922)
kuilt ships no durable RaftStorage — InMemoryRaftStorage is the only implementation in the library, so every persistent adapter is consumer code. Since #1887 the engine refuses to start (CorruptDurableStateException) on durable state it cannot believe: a term, snapshot baseline, or restored entry outside 0..2^60, a log with a gap, or terms that decrease along the log. This suite exists so an adapter bug of that class surfaces at the adapter's own test time rather than at a consumer's startup.
The line this suite holds: it may require an adapter to round-trip faithfully the values the engine's restore checks make load-bearing; it may not require it to reject garbage it was never given. The adapter is not the validation point — the engine is. So every assertion below writes a value the engine's checks admit, and requires it back unchanged. None asserts that an adapter rejects, clamps, or repairs anything.
Functions
The decode-of-absence half, which none of the four properties above can reach: reopening a medium nothing was ever written to must yield the same empty state newStorage promises, not a fabricated one.
The other side of the #1221 window: once RaftStorage.discardLogPrefix does run it removes everything at or below its floor and leaves a contiguous suffix — and, per its contract, it is idempotent and tolerates a floor below the first retained entry (the repeat a node performs after recovering from a crash inside the window).
Mirrors the contiguity half of RaftEngine.checkedRestoredEntries (#1887): entries written contiguously must come back contiguous, in ascending index order, with no gap and no reordering — across however many RaftStorage.appendEntries calls produced them.
The filtered view the engine actually restores from — entries(snapshotIndex + 1) — must be contiguous and ascending too, not just the unfiltered log.
Mirrors the range half of RaftEngine.checkedRestoredEntries (#1887): a restored LogEntry.index is admitted in 0..MAX_PLAUSIBLE and a LogEntry.term in 0..currentTerm, itself bounded by the same ceiling.
LogEntry has six fields; every property above constructs entries from three.
logEntryInternalFields_roundTripPerEntry on the far side of a restart — the boundary these three fields are actually lost at.
The lower edge of the term half of RaftEngine.checkedRestoredEntries — term = 0 is admitted, and an adapter treating 0 as "unset" (a nullable column, a sentinel) corrupts it.
Returns a fresh, empty RaftStorage instance.
The record is one value, not two independent keys. Overwriting it must replace both halves together: an adapter that writes the term and the identity to separate rows can leave the new term beside the old identity, which is precisely the mismatched pin RaftStorage.saveLeaderForTerm's single-write requirement exists to prevent — and the engine has no way to detect it, since a pin at the current term is exactly what it is looking for.
The round trip §3.10 sender-authentication rests on: RaftEngine restores this record on start-up and refuses a TimeoutNow from anyone but the LeaderForTerm.leaderId it names, so both halves must come back exactly as written.
The savesAndLoadsTerm_atPlausibilityEdges argument, applied to the pin's term: it is compared for equality with currentTerm, which the engine admits anywhere in 0..MAX_PLAUSIBLE. A term column that cannot hold a Long therefore does not merely round-trip a slightly different number — it makes the pin permanently invisible at the one term it was written for, and §3.10 transfer to that node fails on its auto-timeout with nothing to point at.
Mirrors RaftEngine.checkedRestoredTerm (#1855): the engine admits a restored storage.term() in 0..MAX_PLAUSIBLE and refuses to start outside it.
RaftStorage.saveSnapshot's contract says it "overwrites any previously stored snapshot", and until this property no test saved two.
The pin and the term/vote pair are independent records. The engine writes them at different moments by construction — the pin on first leader-contact of a term, the term/vote at every term-advance — so an adapter that stores them in one slot, or that clears one when the other is written, loses the pin on the very next heartbeat-driven term observation.
Verifies the §5.1/§5.2 atomicity contract: after RaftStorage.saveTermAndVotedFor both term and votedFor are visible together. Persistent implementations must write both in a single transaction so a mid-write crash cannot leave a node with an advanced term but stale vote (or vice-versa), which would allow it to vote twice in the same term.
The RaftStorage.saveTermAndVotedFor half of savesAndLoadsTerm_atPlausibilityEdges — the atomic writer the engine actually uses at every term-advance site, so it is the path a lossy term column is reached through in practice.
A snapshot saved at the zero baseline must come back present, with both metadata fields intact.
SnapshotMeta has three fields and the suite asserted two — and the third is the one the suite's own KDoc already named as the failure. snapshotAtZeroBaseline_roundTrips argues that an adapter with nullable-with-default metadata columns "loses that config and comes back under the wrong cluster configuration", and then does not assert it: every snapshot above is built SnapshotMeta(index, term), leaving SnapshotMeta.config at its null default, which is precisely what a dropped column decodes to.
Mirrors RaftEngine.checkedRestoredSnapshotMeta (#1887): both halves of SnapshotMeta are admitted up to and including MAX_PLAUSIBLE, so both must survive a save/load cycle exactly.
The precision probe described in snapshotMeta_roundTripsAtPlausibilityCeiling.
RaftStorage.saveSnapshot must be durable before RaftStorage.discardLogPrefix runs, so a crash between the two legally leaves the snapshot plus the un-discarded prefix (#1221). An adapter is not required to have discarded anything at this point, and this suite does not assert that it has.
An empty application state is a real snapshot, not an absent one.
The §5.1/§5.2 pair, on the far side of a restart — the property RaftStorage exists for.
The §3.10 sender-authority pin, on the far side of a restart — and the restart is the only moment it matters at all.
The log, whole and in order, on the far side of a restart — including the filtered read the engine actually restores from.
The snapshot config on the far side of a restart, which is the only side it is ever read on.
The snapshot — metadata and bytes — on the far side of a restart.