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 reachingcomplete, anmsync— 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 (
InMemoryDurableStoreandFileChannelDurableStoreboth 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.
| Mutation | Reds, at assertion granularity |
|---|---|
InMemoryDurableStore.write: keep the caller's array | theStoreDoesNotAliasTheArrayItWasGiven, its one assertion — and nothing else |
InMemoryDurableStore.read: hand back the stored array | theStoreDoesNotAliasTheArrayItHandsBack, its one assertion — and nothing else |
InMemoryDurableStore.delete: no-op | deleteMakesTheKeyAbsentAndWritableAgain 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 byte | everyByteValueSurvivesTheRoundTrip 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 KiB | aLargeValueRoundTripsWhole, both assertions (262144 against 8192) — and nothing else |
FileChannelDurableStore.read: a zero-length file decodes to null | anEmptyValueIsAValueAndNotAnAbsence, both assertions — and nothing else |
FileChannelDurableStore.write: append rather than atomically replace | aSecondWriteReplacesTheFirstWhole 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 characters | aLongKeyIsStillAKey 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 encoding | distinctKeysAddressDistinctEntries 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 given | both restart properties, on the assertNotSame precondition — no durability assertion is reached |
Fixture: restart opens an empty directory | whatWasWrittenBeforeARestartIsReadableAfterIt both Durable assertions; whatWasDeletedBeforeARestartIsStillAbsentAfterIt the sibling assertion only |
| Fixture: a durable backend declares RestartFixture.KeepsNothing | both KeepsNothing assertions of the first restart property, and the KeepsNothing assertion of the second |
| Fixture: the RestartFixture.KeepsNothing arm returns the store it was given | both restart properties on assertNotSame and on their KeepsNothing assertions |
| Fixture: a non-durable backend declares RestartFixture.Durable | the mirror — both Durable assertions of the first, the sibling assertion of the second |
Fixture: newStore hands back one shared store | twoFreshStoresDoNotShareState, 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.
Functions
A key that looks like a path is a key, not a path: it round-trips, and it does not become some other key.
A value far larger than any single buffer on the write or read path, with position-dependent content.
A key considerably longer than the hand-written names a caller normally uses.
An empty value is a value. Writing one and reading it back must give an empty array, not null — the other half of readOfANeverWrittenKeyIsNull.
DurableStore.write "overwrites any previous value" — wholesale, not in place.
Delete makes the key absent — genuinely absent, the same state readOfANeverWrittenKeyIsNull describes — and leaves it usable again.
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.
Distinct keys address distinct entries. Writing one must never change another.
Values are opaque binary, so all 256 byte values must survive — the high half especially.
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.
The reverse: the array DurableStore.read hands back belongs to the caller, so writing into it must not reach the store.
The store must copy what it is given, not keep a reference to the caller's array.
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."
The write/read round trip, plus the fact that a StoreKey addresses by its name and not by object identity.