logEntryInternalFields_roundTripPerEntry

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

That is not an omission with a cosmetic cost. LogEntry.isNoOp left unset is false, LogEntry.config is null and LogEntry.dedupKey is nullexactly the values a dropped field decodes to. So a storage with the obvious (index, term, command) schema, which silently defaults the other three, is green on every property above; the suite writes the defaults and reads the defaults back and cannot tell the two apart. assertEquals(written, read) was already a full six-field check (LogEntry.equals is hand-written and compares all of them) — what was missing was a single entry whose internal fields were not their defaults.

What each dropped field costs the node that restarts on it:

  • LogEntry.isNoOp — the §5.4.2 election no-op restores as application data and is delivered on RaftNode.committed: the application applies a command nobody proposed.

  • LogEntry.config — a §6 membership entry restores as an ordinary one, so it is not adopted on append (the cardinal §6 rule) and the node comes back under the wrong cluster membership. This one is consensus-critical: the restored voter set decides quorum.

  • LogEntry.dedupKey — §8 client-serial dedup is lost, and a client command that already committed is applied a second time after the restart.

Three entries rather than one, each field non-default in exactly one of them and default in the other two, mirroring the three shapes RaftEngine actually writes: the no-op it appends on winning an election, the config entry appendConfigEntry writes, the dedupKey-stamped application entry propose writes.

The measured reason for that arrangement is narrower than the obvious one, and the obvious one is wrong. It is tempting to say the single-entry version would be passed by a storage that hardcodes isNoOp = true. It would — but the suite would still catch it, on four pre-existing properties, because every entry those properties construct leaves isNoOp at false and assertEquals(toAppend, retrieved) compares it (measured: that mutation reds appendsAndRetrievesEntries, appendAfterTruncate_works, logEntryIndexAndTerm_roundTripAtPlausibilityCeiling and theLogSurvivesAReopenWhole alongside these two). The fabrication direction was already covered; only the drop direction was the hole, and that is what this property is for.

What the arrangement genuinely buys, and what nothing else in the suite reaches, is the storage that derives a field instead of persisting it. isNoOp = command.isEmpty() is the plausible shortcut — no-ops do carry an empty command — and it is wrong for the reason LogEntry.command's own KDoc gives: "An application may legitimately propose an empty command — emptiness alone does not mark an entry internal." Every pre-existing entry in this suite has a non-empty command, so all 36 of them are green under that mutation. These two are not, because entry 2 has an empty command and is not a no-op. Measured, and the only mutation in the receipt that no pre-existing property sees.

What this cannot detect: anything about durability. Every read here is off the handle that took the write, so a storage holding these fields in a live object and persisting none of them passes. logEntryInternalFields_surviveAReopen is that half.

Mutation receipt (#2302)

Measured over :kuilt-conformance:jvmTest --tests "*RaftStorage*" (42 tests, green at baseline). One mutation at a time, reverted after, the revert verified with git status; 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.

Every row is a reference-subclass mutation — a decorator written inside InMemoryRaftStorageConformanceTest.kt, which nothing outside that file references, so the confinement is structural rather than measured. No production mutation appears, and none was available: InMemoryRaftStorage holds LogEntry and StoredSnapshot by reference, so it has no field-by-field boundary at which a value could be dropped. That is the same fact that let the hole exist.

"What the pre-existing suite did" is read off the same run rather than assumed: every property added by #2302 is a new method name, so a failure list containing only new names is the measurement that the other 36 were green.

MutationReds, of #2302's propertiesReds, of the 36 pre-existing
appendEntries drops isNoOpthis and logEntryInternalFields_surviveAReopennone
appendEntries drops configthe same twonone
appendEntries drops dedupKeythe same twonone
appendEntries derives isNoOp = command.isEmpty()the same twonone
appendEntries hardcodes isNoOp = truethe same twofour — see above
saveSnapshot drops meta.configsnapshotConfig_roundTrips, theSnapshotConfigSurvivesAReopen, snapshotWithEmptyState_isStillASnapshotnone
saveSnapshot keeps the FIRST snapshotsaveSnapshot_overwritesThePriorSnapshotWhole — 5 arms, same-handle and restartnone
saveSnapshot stores nothing when state is emptysnapshotWithEmptyState_isStillASnapshotnone
reopen drops the entries' internal fieldslogEntryInternalFields_surviveAReopen onlynone
reopen drops meta.configtheSnapshotConfigSurvivesAReopen onlynone
write-through cache whose restart-side read has no ORDER BY … DESCsaveSnapshot_overwritesThePriorSnapshotWhole — the 2 restart arms onlynone
Fixture: internalFieldEntries() reverts to the three defaultsboth log properties, on the precondition
Fixture: JOINT_CONFIG becomes simple and voters-only4 properties, on the precondition
Fixture: the superseded snapshot's config becomes nullsaveSnapshot_overwritesThePriorSnapshotWhole, on the precondition
Fixture: the empty snapshot state gains a bytesnapshotWithEmptyState_isStillASnapshot, on the precondition

The last three rows of the first block are the pair-splitting evidence. Each reds a restart property and not its same-handle sibling, which is what makes the pairs two properties rather than one counted twice. The ORDER BY row is the sharpest: the same property reds on 5 arms under "keeps the first snapshot" and on exactly the 2 restart arms here, so the shape of the red distinguishes the two adapter bugs — read the arms, not the test count.

The fixture rows red the precondition and leave the round-trips green, which is the point of having them: under fixture drift the property does not fail, it stops asserting. Those four arms are the only thing that turns a silent vacuity into a red.

The admission the table makes if you read it the other way: nothing reds either same-handle property alone. Every mutation that reaches this one or snapshotConfig_roundTrips also reaches its restart sibling, and structurally that cannot be otherwise over this referenceInMemoryRaftStorageConformanceTest.reopen rebuilds through entries() and loadSnapshot(), the same reads the same-handle properties perform, so a loss on the near side is a loss on the far side too. Over the reference alone the two same-handle properties are therefore subsumed. They are not written for the reference. Their independent value for an adapter is that they do not depend on reopen being implemented correctly at all: an adapter that fails the assertNotSame precondition in reopened still gets a clean, named report of which field its schema drops, instead of four properties failing on the fixture. Same shape as #2301's own finding, inverted — and worth saying rather than leaving a reader to infer the pairs are independent.

Almost every cell in the right column is "none", and that is the finding rather than a suspiciously clean table — it is the literal statement of #2302. The one row that is not "none" is in the table because it disproves a claim this KDoc made before it was measured.

Unmeasured, and named rather than left to look covered: two arms of assertOverwriteFixtureIsAttributable — that the two snapshots' indices differ, and that their terms differ — are reddened by nothing above. They guard a future fixture edit that collapses the two records onto one baseline, which no mutation here models; they are assertions written against a drift, not against a bug, and no measurement has moved them.