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 RaftStorageInMemoryRaftStorage 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.

Constructors

Link copied to clipboard
constructor()

Functions

Link copied to clipboard

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.

Link copied to clipboard
fun appendAfterTruncate_works(): TestResult
Link copied to clipboard
fun appendsAndRetrievesEntries(): TestResult

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.

Link copied to clipboard
fun entriesFromIndex_filters(): TestResult
Link copied to clipboard
fun entriesOnEmptyLog_isEmpty(): TestResult
Link copied to clipboard
fun initialTermIsZero(): TestResult
Link copied to clipboard
fun initialVotedForIsNull(): TestResult
Link copied to clipboard
Link copied to clipboard

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.

Link copied to clipboard

LogEntry has six fields; every property above constructs entries from three.

Link copied to clipboard

logEntryInternalFields_roundTripPerEntry on the far side of a restart — the boundary these three fields are actually lost at.

Link copied to clipboard

The lower edge of the term half of RaftEngine.checkedRestoredEntriesterm = 0 is admitted, and an adapter treating 0 as "unset" (a nullable column, a sentinel) corrupts it.

Link copied to clipboard
abstract fun newStorage(): RaftStorage

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.

Link copied to clipboard
fun savesAndLoadsLeaderForTerm(): TestResult

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.

Link copied to clipboard
fun savesAndLoadsTerm(): TestResult
Link copied to clipboard

Mirrors RaftEngine.checkedRestoredTerm (#1855): the engine admits a restored storage.term() in 0..MAX_PLAUSIBLE and refuses to start outside it.

Link copied to clipboard
fun savesAndLoadsVotedFor(): TestResult

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.

Link copied to clipboard

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.

Link copied to clipboard
Link copied to clipboard
fun saveVotedForNull_clearsVote(): TestResult
Link copied to clipboard

A snapshot saved at the zero baseline must come back present, with both metadata fields intact.

Link copied to clipboard
fun snapshotConfig_roundTrips(): TestResult

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.

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.

Link copied to clipboard

An empty application state is a real snapshot, not an absent one.

Link copied to clipboard
fun termAndVoteSurviveAReopen(): TestResult

The §5.1/§5.2 pair, on the far side of a restart — the property RaftStorage exists for.

Link copied to clipboard

The §3.10 sender-authority pin, on the far side of a restart — and the restart is the only moment it matters at all.

Link copied to clipboard
fun theLogSurvivesAReopenWhole(): TestResult

The log, whole and in order, on the far side of a restart — including the filtered read the engine actually restores from.

Link copied to clipboard

The snapshot config on the far side of a restart, which is the only side it is ever read on.

Link copied to clipboard
fun theSnapshotSurvivesAReopen(): TestResult

The snapshot — metadata and bytes — on the far side of a restart.

Link copied to clipboard