[gauntlet:reference-ec2-vpc/day2_crash] destroying a deposed object recorded no tombstone, so its lingering tag was a second claimant - #943
Merged
Conversation
…sed object records it as destroyed (#938) HANDOFF row 1: choudoufu refuses where stock proceeds. reference-ec2-vpc's day2_crash H3 refused with "Two live resources claiming one address" naming the instance H2 had just terminated. Bisect, by reading: of the three merges that landed between eec6fb4 (passing) and 22d9a3f (failing), only #920 (001fd5d, issue #901) touches this path - #932 is an identity.Class handler-table refactor in projection/command/mv/liveimport and #935 is discovery's double-sighting dedupe of ONE object seen by two enumeration legs, which cannot merge two distinct instance IDs. #920 is correct about the apply it fixed and left the apply after it uncovered: H1 (crashed create_before_destroy replace) records no tombstone, because the deposed object is alive - #901, and right. H2 (the recovery apply) destroys it. Its plan schedules a deposed destroy, not a replace, so ReplacedAddrs is empty; the address's recorded identity has named the new object since H1, so identitySuperseded is false. Neither of supersedeIdentity's two facts holds, and the identity this estate's own apply terminated is recorded nowhere. H3 reads the terminated object's lingering tag as a second live claimant. The generic rule, on the write side and with no type name in it: an apply that destroys a deposed object records that identity as destroyed by this estate, from the plan's own Delete of that deposed key (WriteBackRequest.DestroyedDeposed, derived by backend/local's destroyedDeposedInstances exactly as #854's replace set is) AND the final state no longer carrying the key as deposed. The identity written is the one the record already holds under that key, rendered by LocatedRecordFrom on the crashed apply's own write-back, so this reaches every recordable type by copying a payload rather than deriving a second one. #901's suppression holds unchanged at the second apply too: a destroy leg that failed again leaves the key in ri.Deposed and records nothing. Red proof, with tombstoneDestroyedDeposed returning 0 (main's behaviour): --- FAIL: TestWriteBackDestroyingADeposedObjectRecordsItAsDestroyed after the apply that destroyed the deposed object the record names [] as destroyed by this estate, want exactly ["eipassoc-00112233445566778"]. --- FAIL: TestDiscover_crashWindowClosedLeavesOneClaimant after the recovery apply the record names [] as destroyed by this estate, want [vpc-old] - GitHub issue #938 and with the still-deposed guard removed, the other direction: --- FAIL: TestWriteBackDeposedDestroyThatFailedAgainRecordsNothing a deposed destroy that failed again recorded [eipassoc-00112233445566778] as destroyed, want none --- FAIL: TestDiscover_crashWindowStillOpenKeepsBothClaimants a deposed destroy that did not happen recorded map[vpc-old:...] as destroyed: vpc-old is deposed and still running internal/live/discovery/crashwindowclose_test.go is deliberately the only test in that package that builds its record by RUNNING projection.WriteBack rather than seeding one: every seeded superseded-claimant test passed throughout, because the shape the write half actually produced after a crashed replace was one no seeded test ever asked for. Refs #938, #901, #920, #670, #854, #908. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
…stroy set reaches WriteBack on a real apply (#938) TestWriteBackSeesTheReplaceSetAfterApply's twin for the new signal: a real plan and graph walk over a prior state in the crashed-replace shape (current object plus a deposed one), asserting the set WriteBack is handed by rendered value. Proven red by inverting destroyedDeposedInstances' deposed filter: --- FAIL: TestWriteBackSeesTheDeposedDestroySetAfterApply WriteBack was handed the deposed-destroy set [], want [test_instance.foo deadbeef]. Measured, not assumed: unlike #908's replace set, a deposed Delete SURVIVES lr.Core.Apply in plan.Changes - computing the set at the WriteBack call site still passes this test, because the apply-side deposed node does not call writeChange(nil, key). The computation sits beside replacedInstances(plan) anyway; the test's doc comment says exactly this so nobody reads it as a drain guard it is not. Refs #938, #908. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
…ay2_crash fail -> pass (#938) Re-measured on top of #939's all-27 re-measure, at f6b0554 against the pinned emulator (ghcr.io/lex00/floci@sha256:a39185cc3971d0188663d61043cb038dff1260d8a975b1aa72c4e2bb1feac3cb), oracle terraform 1.16.0 / tofu 1.12.6. Main's row for this estate, written by #939, read day2_crash=fail with detail "the post-recovery plan exited 1". This run's verdict lines: GAUNTLET stage=cold_deploy verdict=pass GAUNTLET stage=greenfield verdict=pass GAUNTLET stage=migrate verdict=pass GAUNTLET stage=test_plan verdict=pass GAUNTLET stage=test_apply verdict=pass GAUNTLET stage=drift_reconverge verdict=pass GAUNTLET stage=plan_approval verdict=pass GAUNTLET stage=day2_replace verdict=pass GAUNTLET stage=day2_rename verdict=pass GAUNTLET stage=day2_remove verdict=pass GAUNTLET stage=day2_count verdict=pass GAUNTLET stage=day2_crash verdict=pass GAUNTLET stage=strict verdict=pass The only stage that moved is day2_crash, fail -> pass; the board's day2_crash tally goes 0 pass / 1 fail / 26 not_run to 1 pass / 0 fail / 26 not_run, and the two headline bars are unchanged at core 26 of 26 and all 27 of 27 (this stage is planned, so it is not counted in either). The rebase conflict on live/gauntlet.json and the three rendered files was resolved by taking main's copies wholesale and re-running the estate, never by hand-merging - CLAUDE.md, "Measured artifacts are never hand-merged". Refs #938, #939, #903. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The unit
reference-ec2-vpc/day2_crash, tracked as #938. The stage isplanned, soit is not on either headline bar and
gauntlet nextdoes not name it; theestate reads clear either way.
The five-row class
Row 1: choudoufu refuses where stock proceeds. Stock's state file names
the surviving object outright and replans the estate without a word. This
fork refused it, totally, until AWS forgot the terminated instance:
The bisect
Three PRs merged into the window between
eec6fb4282(last pass) and22d9a3f099(fail). Done by reading, not by assuming the lead:12ab0e5493)identity.Classhandler tables in projection/command/mv/liveimport/lintwriteback.gois untouched and no tombstone or deposed path moves.c6aebde9f5)001fd5dd8a, issue #901)writeback.go's tombstone gategit diff 001fd5dd8a^1 001fd5dd8a -- internal/live/projection/writeback.gois the whole change:
deposedMayStillHoldwas added to the switch thatdecides whether a replace records a destroyed identity.
What was wrong
#920 fixed H1 and uncovered H2. The stage's three applies:
create_before_destroyreplace is interrupted between thecreate committing and the destroy dispatching. The record correctly ends
up
current=i-new, deposed=i-old, and tombstones: no tombstone for a deposed object whose destroy leg failed (#901) #920 correctly writes no tombstone:the deposed object is alive.
a deposed destroy, not a replace, so
ReplacedAddrsis empty; theaddress's recorded identity has named the new object since H1, so
identitySupersededis false. Neither ofsupersedeIdentity's twofacts holds, so the identity this estate's own apply terminated was
recorded nowhere at all.
tagging API (discovery: a replace should tombstone the object it destroys, so a superseded claimant is provably dead rather than merely unrecorded #670's lingering-tag case, and AWS's own documented behaviour
for a terminated instance), so the sweep hands the address two claimants
and nothing says one of them is dead. Refusal.
Before #920 this passed for the wrong reason: H1 wrote a tombstone for a
deposed-and-alive object, which #901 is right to call a lie about a live
resource. The fix is not to put that lie back.
The generic rule
An apply that destroys a deposed object records that identity as destroyed
by this estate, on two facts and no type name:
WriteBackRequest.DestroyedDeposed,derived by
backend/local's newdestroyedDeposedInstances(plan)exactlyas tombstones: derive the replace signal from the plan, not from the record's identity changing (import and mv wording) #854's replace set is derived by
replacedInstances(plan). The twopartition the plan's destroy evidence:
IsReplace()already excludes adeposed-key change, and this takes only deposed-key
Deletes.verb says a destroy was scheduled, which is precisely the gap tombstones: a create_before_destroy replace whose destroy fails records a live deposed object as destroyed #901
exists for. A destroy leg that fails again leaves the key in
ri.Deposedand records nothing.
The identity written is the one the record already holds under that key,
rendered on the crashed apply's own write-back by
LocatedRecordFrom. So therule reaches every recordable type by copying a payload an existing writer
produced rather than deriving a second one, and it is called from the one
place both write-back loops already share, beside
diffDeposedForWrite.How many types it reaches. No
aws_*name appears anywhere in the change,and no entry is needed in
live/derivation_guard_test.go's registry. Measuredagainst a real
hashicorp/aws6.59.0GetProviderSchemaresponse(
pluginschema.Acquire, throwaway probe, not committed): of 1699 managedresource types, 1462 have a recordable identity plan (90 composite, 1372
scalar) — that is the population whose deposed entry carries an identity, and
therefore the population this rule can write an entry for. The remaining 237
render no identity, and
addTombstoneEntry's own empty check leaves themsilent, which is the same "unproven, so stay quiet" direction
deposedMayStillHoldtakes.Red, then green
Both directions, so neither guard is one that cannot fail.
With
tombstoneDestroyedDeposedreturning 0 (main's behaviour):With the still-deposed guard removed (i.e. #901's suppression broken):
With
destroyedDeposedInstances' deposed filter inverted:internal/live/discovery/crashwindowclose_test.gois deliberately the onlytest in that package that builds its record by running
projection.WriteBackinstead of seeding one. Every seeded superseded-claimanttest passed throughout this regression, because the shape the write half
actually produced after a crashed replace was one no seeded test ever asked
for: no deposed entry, and no tombstone either.
The estate: reproduce, then fix
Not just unit tests. With
tombstoneDestroyedDeposedneutered to return 0 andeverything else identical, on this same worktree and emulator:
and with the fix in:
Before (main's row, written by #939)
After (this branch, all thirteen verdict lines)
Nothing moved backwards. The board's
day2_crashtally goes 0 pass / 1 fail /26 not_run to 1 pass / 0 fail / 26 not_run; the two headline bars stay at core
26 of 26 and all 27 of 27, since this stage is
planned.The rebase onto #939 conflicted on
live/gauntlet.jsonand the three renderedfiles. Resolved by taking main's copies wholesale and re-running the estate,
never by hand-merging.
#920's open question: is the read side's deposed-over-tombstone ordering still needed?
Yes, and it is now a compatibility leg rather than a live one.
pruneSupersededEntrykeeps a claimant that matches a deposed record evenwhen a tombstone names the same object. After this change the write side can
no longer produce that record from one estate's own applies: the entry is
written only for a key that has LEFT
ri.Deposed, anddiffDeposedForWritedeletes that key inside the same mutate closure, so deposed-and-tombstoned is
not a state a current build reaches.
What still reaches it is what #920's own comment named: a record written by an
estate running an older build (every pre-#920 crashed apply wrote exactly that
shape), a hand-edited record, or a future writer. The ordering costs one
refusal when it is wrong and prevents a live object being pruned out of a
collision set when it is right, so it stays.
TestDiscover_crashWindowStillOpenKeepsBothClaimantsnow pins the livedirection of it end to end.
Reproduce
Unit level:
Testing
gofmt -lclean,go build ./...,go vet ./..., andgo test -count=1 ./internal/live/projection/ ./internal/live/discovery/ ./internal/live/check/ ./live/ ./tools/gauntlet/ ./internal/backend/local/ ./internal/live/marksafe/all green.gauntlet checkreports rendered filescurrent.
Refs #938, #901, #920, #670, #854, #908, #939.
🤖 Generated with Claude Code
https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL