DurableStoreConformanceSuite

Reusable contract test suite for DurableStore implementations.

Subclass and implement newStore and restart to bind any store under test. Every Test in this class encodes a required invariant of the DurableStore contract — a conforming implementation must pass all of them.

Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every backend can subclass it from its own test source set, on whichever targets that backend exists for.

class SqliteDurableStoreConformanceTest : DurableStoreConformanceSuite() {
private val files = mutableMapOf<DurableStore, File>()
override suspend fun newStore(): DurableStore =
SqliteDurableStore(tempFile()).also { files[it] = it.file }
override suspend fun restart(store: DurableStore): RestartFixture =
RestartFixture.Durable(SqliteDurableStore(requireNotNull(files[store])))
}

Why this exists

kuilt ships four independently written DurableStore backends — InMemoryDurableStore in commonMain, FileChannelDurableStore on JVM/Android, NSFileManagerDurableStore on Apple, IndexedDbDurableStore on wasmJs — behind one three-method interface, and until this suite each was verified only by its own hand-written test file. That is the shape #2240 produced the identical cross-segment defect through twice, in a module where two workers never saw each other's code: a property nobody writes for the shared contract is a property every backend has to invent alone, and most of them invent a different one.

What this suite deliberately does not require

  • Iteration, enumeration or transactions across keys. The contract has three methods and no listing surface, so nothing here may depend on one.

  • Any particular durability mechanism. fsync-then-rename, an IndexedDB transaction reaching complete, an msync — the suite asks only that a value written before a restart is readable after it, through restart, and a backend that promises no such thing declares RestartFixture.KeepsNothing rather than opting out.

  • Concurrency. Every property here is sequential. A backend's thread-safety obligations are real (InMemoryDurableStore and FileChannelDurableStore both take locks for them) but they are not reachable from a suite that runs under one test dispatcher, so they stay each backend's own tests' business rather than being half-tested here.

  • Key length past LONG_KEY_CHARS. See aLongKeyIsStillAKey for the bound and the reasoning.

Mutation receipts

Measured over :kuilt-store:jvmTest — 54 tests: InMemoryDurableStoreConformanceTest (this suite's 16), FileChannelDurableStoreTest (those 16 again) and FileChannelDurableStoreFilenameTest (7 filename properties, which #2515 moved out of FileChannelDurableStoreTest into DurableStoreFilenameConformanceSuite and re-measured there), StoreKeyFilenameTest (14, the shared encoder's own guards) and StoreSamplesRunTest — with --no-build-cache --rerun-tasks, the results XML deleted before every run and the log grepped for compile errors, because a mutation that does not compile leaves Gradle serving the previous run's XML and fabricates a plausible copy of the row above it. Each mutation applied alone, reverted, the revert verified with git status.

The baseline is all-green — and it was not when this suite was written. On its first run distinctKeysAddressDistinctEntries failed on FileChannelDurableStore (5 of its assertions) and on NSFileManagerDurableStore (4 of them, a different 4): two independently written sanitisers, each folding a different set of distinct keys onto one file, and neither backend's own tests noticing. That was this suite doing the thing it exists for, on the day it landed. #2511 is the fix — one shared lossless encoder, encodeStoreKeyName, with no migration — so the exclusion that used to sit here has been retired rather than carried: every "reds" entry below is now measured against a genuinely green baseline, with nothing held out of it, and a row naming a red is naming a red its own mutation caused.

Retiring an exclusion re-asserts every row that was measured under it, so the four file-backend rows were re-measured rather than inherited — the ones whose "and nothing else" had been stated while a failure on that very backend was being held out. Three came back unchanged. The fourth had drifted: append rather than atomically replace now reds a third property, because #2511 added the test that reaches it. The five InMemoryDurableStore rows and the six fixture rows are untouched by any of this — the excluded failure was never on their backend, and a mutation to one class cannot red another.

MutationReds, at assertion granularity
InMemoryDurableStore.write: keep the caller's arraytheStoreDoesNotAliasTheArrayItWasGiven, its one assertion — and nothing else
InMemoryDurableStore.read: hand back the stored arraytheStoreDoesNotAliasTheArrayItHandsBack, its one assertion — and nothing else
InMemoryDurableStore.delete: no-opdeleteMakesTheKeyAbsentAndWritableAgain assertion 1; assertion 2 unreached
InMemoryDurableStore.read: absence decodes to ByteArray(0)six properties — readOfANeverWrittenKeyIsNull; deleteOfAnAbsentKeyIsANoOp a1; twoFreshStoresDoNotShareState a1; deleteMakesTheKeyAbsentAndWritableAgain a1; both KeepsNothing arms of whatWasWrittenBeforeARestartIsReadableAfterIt; a2 and a3 of whatWasDeletedBeforeARestartIsStillAbsentAfterIt. Not anEmptyValueIsAValueAndNotAnAbsence, which asserts the other direction
InMemoryDurableStore.write: mask the high bit off every byteeveryByteValueSurvivesTheRoundTrip a2, first mismatch at index 128; aLargeValueRoundTripsWhole a2. writtenBytesComeBackExactly stays green — every byte in its payload is below 0x80, which is why those two are not one property
FileChannelDurableStore.read: truncate at 8 KiBaLargeValueRoundTripsWhole, both assertions (262144 against 8192) — and nothing else
FileChannelDurableStore.read: a zero-length file decodes to nullanEmptyValueIsAValueAndNotAnAbsence, both assertions — and nothing else
FileChannelDurableStore.write: append rather than atomically replaceaSecondWriteReplacesTheFirstWhole all three; whatWasWrittenBeforeARestartIsReadableAfterIt the overwritten assertion only, not kept; and FileChannelDurableStoreFilenameTest.anEntryNeverLandsOnAnotherEntrysTempSidecar on its "x" assertion ([1, 3] where [3] was written). That third red is new since this row was first measured — #2511 added the test that reaches it — which is why the row states its reds rather than claiming "and nothing else"
StoreKey.filename: truncate the encoded name to 64 charactersaLongKeyIsStillAKey a1 (expected 1, got 2) — and nothing else. The one-sided shape is the point: both keys encode to the same 64-character prefix, so the second write lands on the first's entry and only the first key's read is wrong. a2 reads what it wrote and stays green
StoreKey.filename: lowercase() the key name before encodingdistinctKeysAddressDistinctEntries the "a-b" assertion only (expected 4, got 5 — "a-B" landed on it), plus FileChannelDurableStoreFilenameTest.keysDifferingOnlyInCaseAddressDistinctEntries. This is the case pair's receipt, and it reds on every filesystem, because the fold is the store's own
encodeStoreKeyName's safe set: put A–Z back in it (undo #2511's uppercase escaping)the same "a-b" assertion — but only because the measuring box's temp root is APFS. a-b and a-B become two distinct filenames that a case-folding filesystem makes one file; on a case-sensitive one this mutation reds nothing in this suite at all. It does red four of StoreKeyFilenameTest's encoder guards, which is where that boundary is pinned target-independently, and is why the suite is not the place to rely on it
FileChannelDurableStore: drop FileChannel.force(true)nothing. See the residual below
Fixture: restart returns the store it was givenboth restart properties, on the assertNotSame precondition — no durability assertion is reached
Fixture: restart opens an empty directorywhatWasWrittenBeforeARestartIsReadableAfterIt both Durable assertions; whatWasDeletedBeforeARestartIsStillAbsentAfterIt the sibling assertion only
Fixture: a durable backend declares RestartFixture.KeepsNothingboth KeepsNothing assertions of the first restart property, and the KeepsNothing assertion of the second
Fixture: the RestartFixture.KeepsNothing arm returns the store it was givenboth restart properties on assertNotSame and on their KeepsNothing assertions
Fixture: a non-durable backend declares RestartFixture.Durablethe mirror — both Durable assertions of the first, the sibling assertion of the second
Fixture: newStore hands back one shared storetwoFreshStoresDoNotShareState, both assertions — and nothing else in the suite

The row that reds nothing is the important one, and it is this suite's largest residual. Dropping the fsync moves no assertion at all. A restart modelled inside one process reopens through the operating system's page cache, so the bytes are readable whether or not they ever reached the device — and every restart in this tree has that shape. So what this suite actually establishes is that a write reached the medium's namespace, not that it reached stable storage. Telling those apart needs a real process kill or a fault-injecting filesystem, neither of which a commonMain suite can have. Said plainly so nobody reads a green here as a durability proof: NSFileManagerDurableStore does not force before its rename at all (#2141), documents that, and passes every property below.

What the fixture rows are and are not. The six fixture rows mutate a subclass in :kuilt-store's own tests, which nothing else references, so their "and nothing else" is structural — no other test could see them. The twelve production rows mutate code that also backs :kuilt-otel and everything downstream of it, and were measured only within :kuilt-store:jvmTest; their true blast radius is larger than the rows say, not smaller. The three filename rows reach a whole backend that run does not contain: StoreKey.filename and encodeStoreKeyName are commonMain, so NSFileManagerDurableStore is mutated too. The lowercase() row was therefore re-run against :kuilt-store:macosArm64Test, where it reds the same two things — distinctKeysAddressDistinctEntries' "a-b" assertion, and the Apple backend's keysDifferingOnlyInCaseAddressDistinctEntries (now NSFileManagerDurableStoreFilenameTest's, via DurableStoreFilenameConformanceSuite). That second red is the one worth having: it is what establishes that the filename properties still bite in their new conformance-subclass shape, rather than having been carried across into a fixture that cannot fail them. #2515 re-ran it after moving them into a suite and it still reds, plus that suite's own case-folding property.

What the suite itself now rests on, since a fix is only as good as what nothing checks: the fixture, in exactly two places, and both are checked rather than assumed. newStore really returning independent stores — the last row is the only thing in the suite that notices a shared one — and restart really crossing a handle boundary, which assertNotSame and the two wrong-arm rows cover. What stays unpinned is a restart that hands back a thin delegating wrapper, and the page-cache residual above.

And what the newest addition rests on: the case pair in distinctKeysAddressDistinctEntries has one of its two failure modes pinned unconditionally and the other pinned only by the filesystem the run happens to sit on — the third row above is that dependency, measured rather than asserted. So a green here on a case-sensitive runner is worth exactly the first mode and nothing more, and a reader who wants the second must look at StoreKeyFilenameTest, which decides it from the encoded strings and needs no filesystem at all.

Constructors

Link copied to clipboard
constructor()

Functions

Link copied to clipboard

A key that looks like a path is a key, not a path: it round-trips, and it does not become some other key.

Link copied to clipboard
fun aLargeValueRoundTripsWhole(): TestResult

A value far larger than any single buffer on the write or read path, with position-dependent content.

Link copied to clipboard
fun aLongKeyIsStillAKey(): TestResult

A key considerably longer than the hand-written names a caller normally uses.

Link copied to clipboard

An empty value is a value. Writing one and reading it back must give an empty array, not null — the other half of readOfANeverWrittenKeyIsNull.

Link copied to clipboard

DurableStore.write "overwrites any previous value" — wholesale, not in place.

Link copied to clipboard

Delete makes the key absent — genuinely absent, the same state readOfANeverWrittenKeyIsNull describes — and leaves it usable again.

Link copied to clipboard
fun deleteOfAnAbsentKeyIsANoOp(): TestResult

DurableStore.delete's contract is "No-op if the key is absent" — so deleting a key nothing ever wrote must return normally, and must leave the store as it found it.

Link copied to clipboard

Distinct keys address distinct entries. Writing one must never change another.

Link copied to clipboard

Values are opaque binary, so all 256 byte values must survive — the high half especially.

Link copied to clipboard

The one thing a caller does at startup: ask for state the previous session may never have written. Absence is null, not an empty array and not a throw.

Link copied to clipboard

The reverse: the array DurableStore.read hands back belongs to the caller, so writing into it must not reach the store.

Link copied to clipboard

The store must copy what it is given, not keep a reference to the caller's array.

Link copied to clipboard

The fixture precondition the rest of this file rests on: newStore hands back a store that shares no medium with the last one.

A DurableStore.delete that happened before the restart must still have happened after it.

The property DurableStore exists for: "A crash after DurableStore.write returns implies the bytes survive a restart and are returned by the next DurableStore.read."

Link copied to clipboard
fun writtenBytesComeBackExactly(): TestResult

The write/read round trip, plus the fact that a StoreKey addresses by its name and not by object identity.