tombstones: no tombstone for a deposed object whose destroy leg failed (#901) - #920
Conversation
…stroyed A create_before_destroy replace whose destroy leg fails leaves the old object deposed and alive, and the plan still calls the action CreateThenDelete - so #854's plan-derived gate admits it and supersedeIdentity records the live object as destroyed. Red, against the unfixed write side: tombstonedeposed_test.go:156: a create_before_destroy replace whose destroy leg failed recorded [eipassoc-00112233445566778] as destroyed, want none. The control (a replace at an address carrying an UNRELATED deposed object must still tombstone) passes, so the test is about the identity being superseded rather than about the address having a deposed object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
…failed supersedeIdentity rests on two facts: the record's identity moved (identitySuperseded) and the plan scheduled a replace (#854's ReplacedAddrs). A create_before_destroy replace whose destroy leg FAILED satisfies both and destroyed nothing - the planned action is CreateThenDelete either way, and the old object finishes the apply deposed and alive. writeBackRecordEnvelopes now asks the final state's own deposed objects whether the identity being superseded is one of them, through the same LocatedRecordFrom + tombstoneKey pair the read half matches claimants with, and writes no entry when it may be. An unrenderable deposed object answers "may be": suppressing costs a refusal the next plan makes anyway on that object's own account, and supersedeIdentity's own rule is that writing no entry is the strictly louder direction. Nothing names a concrete provider type: the rule is a property of the address's final state (ri.Deposed) and of the identity the type's own schema renders. --- PASS: TestWriteBackReplaceWithAFailedDestroyLegTombstonesNothing --- PASS: TestWriteBackReplaceStillTombstonesWhenTheDeposedObjectIsSomeoneElse --- PASS: TestWriteBackReplaceSuppressesTheTombstoneForAnUnreadableDeposedObject ok github.com/intentius/choudoufu/internal/live/projection 1.579s Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
TestSupersededClaimant_theGuardsCanFail's "a recorded deposed claimant
is kept, not pruned" case says in its comment that deposed wins over a
tombstone naming the same object, but sets no tombstone - so the
claimant it keeps is kept by the tombstone leg's own "nothing records
it as destroyed" exit, and the ORDER of the two legs was pinned by
nothing. No other case in the table sets both for one object either.
Adds the case that does, and proved it load-bearing by mutation:
disabling claimantMatchesAnyDeposed in pruneSupersededEntry fails the
new case alone, every other case in the table still passing.
--- FAIL: .../a_deposed_claimant_is_kept_even_when_a_tombstone_names_it
supersededclaimant_test.go:793: kept claimants [vpc-new], want [vpc-old vpc-new]
supersededclaimant_test.go:796: 1 displaced reports, want 0
This leg stays as the second line of defence now the write side is
correct: a record written by an older build, a hand-edited one, or a
future writer can still present the shape, and what the ordering costs
when it is wrong is one refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
|
Answering this PR's open question - whether the read side's deposed-over-tombstone ordering in Yes, and it is now a compatibility leg rather than a live one. #943 makes the apply that DESTROYS a deposed object record it as destroyed, from the plan's own Delete of that deposed key plus the final state no longer carrying the key. The entry is written only for a key that has left What still reaches it is exactly what this PR's own comment named: a record written by an estate on a pre-#920 build (every crashed Also worth recording here: this PR's suppression was right, and the day2_crash stage passing before it was passing for the wrong reason - H1 was writing a tombstone for a deposed-and-alive object. #943 does not put that back. |
Closes #901.
What was wrong
supersedeIdentityrests on two facts, and issue #854 added the second:identitySupersededsays the record's identity moved, andWriteBackRequest.ReplacedAddrssays this run's plan scheduled a replace forthat address. A
create_before_destroyreplace whose destroy leg FAILSsatisfies both and destroyed nothing: the create commits, the old object is
deposed, the destroy fails, and the planned action was
CreateThenDeletethroughout. The apply's final state finishes with
Currentnaming the newobject and
ri.Deposedstill holding the old one - alive, running, billed -and a tombstone was written for it.
A tombstone's own meaning is "this estate's apply destroyed this identity".
Nothing renders it today, because
pruneSupersededEntry's deposed leg runsbefore its tombstone leg, so the protection was one leg's ordering rather than
the write being correct.
The fix
writeBackRecordEnvelopesnow asks the address's own final state whether theidentity being superseded may still be one of its deposed objects, and writes no
entry when it may (
deposedMayStillHold,internal/live/projection/writeback.go).It reads
ri.Deposed- the same mapdiffDeposedForWritereads one closurelater - through the same
LocatedRecordFromevery identity writer in that filealready uses, and compares on
tombstoneKey, the key the entry would have beenwritten under and the key discovery's
claimantMatchesAnyTombstonelater matchesa live claimant against. One
ifbecame a four-armswitch, plus the helper andits doc.
A deposed object whose identity cannot be rendered at all answers "may be", not
"is someone else": it may BE the superseded identity, and
supersedeIdentity'sown rule for that doubt is that writing no entry is the strictly louder
direction. What suppressing costs is a refusal the next plan makes anyway on
that deposed object's own account.
Genericity. No provider type name appears in the change: the rule is a
property of the address's final state (
ri.Deposed, which every managed resourceinstance has) and of the identity the type's own schema renders. It reaches every
type
writeBackRecordEnvelopes's identity half reaches - every non-record-backedmanaged type the provider schema admits - because the only thing it asks about a
type is what
LocatedRecordFromalready answers for it. Nothing was added tolive/derivation_guard_test.go's registry because nothing was hand-wired.Five-row table. Row 2 (the plans or the resulting cloud differ), one step
back: the record an apply writes is what the next plan reads, and this apply
wrote a false fact about a live object into it.
Red, then green
The test (
internal/live/projection/tombstonedeposed_test.go) builds the exactshape with fakes: a first apply records
eipassoc-0011..., then a replace whosefinal state carries the new object as
Currentand the old one underDeposed["deadbeef"], with the address inReplacedAddrs.Against the unfixed write side:
The middle one is the control that keeps the suppression about THE IDENTITY
BEING SUPERSEDED rather than about the address having any deposed object: a
replace at an address carrying an unrelated, left-over deposed object must still
write the entry, and it passes on both sides of the fix.
After the fix:
The headline test also asserts the second half of #901's "Do": the deposed object
is still recorded under
deposedafter the fixed write-back, by key and by id.That is what keeps it a live claimant for
pruneSupersededEntryand findable formatchDeposedClaimant. A "fix" that suppressed the tombstone by dropping thedeposed entry would have satisfied the tombstone assertion and lost the object.
Both touched packages, uncached:
gofmt -lis clean on all four touched files.Is
pruneSupersededEntry's deposed-over-tombstone ordering still needed?Yes, and it is now pinned - it was not before.
TestSupersededClaimant_theGuardsCanFail's case "a recorded deposed claimant iskept, not pruned" says in its own comment that deposed wins over a tombstone
naming the same object, but it sets no tombstone, so the claimant it keeps is
kept by the tombstone leg's own "nothing records it as destroyed" exit. No case
in that table set both for one object. This PR adds the one that does, and proves
it load-bearing by mutation rather than by assertion: disabling
claimantMatchesAnyDeposedinpruneSupersededEntryfails the new case andnothing else in the table.
(every other subtest still PASS; the mutation was reverted before committing).
The leg stays as the second line of defence. The write is correct from this
commit forward, but the record outlives the build that wrote it: an estate whose
record was written by an older build carries exactly this shape, and so does a
hand-edited one. What the ordering costs when the tombstone is the honest one is
a refusal; what dropping it would cost is a live, running object pruned out of
the collision it belongs in.
Smoke claim
Filed as #919: a step on claim 18 (
live/smoke/scenarios/a-shadow-is-not-a-claimant.sh)that reads the record off disk after a
create_before_destroyreplace whosedestroy leg failed and asserts
tombstonedoes not name the deposed instance,with a
BREAK=1arm that patches the pre-#901 entry back in and requires the runto catch it. Claim 18's existing step 4 is its control, so the new assertion
cannot pass vacuously.
Reproduce
Per the maintainer's brief for this unit, the full tier was not run here.
🤖 Generated with Claude Code
https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL