Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions tests/blackbox/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1004,10 +1004,13 @@ func correctModelFromCommandOutcome(t *testing.T, cmd *apimodel.Command, model *
}

// Step 2: Handle snapshotted slots not mentioned in the command response.
// If the command failed/canceled, unmentioned slots whose state changed
// from the snapshot must be reverted (implicit reconcile deletes or
// cascade descendants that never ran). Skip slots already corrected by a
// later command.
// If the command failed/canceled, an unmentioned slot was never touched
// by the agent, so every optimistic prediction for it (state from
// implicit reconcile deletes or cascade descendants that never ran, AND
// properties from a patch/update the agent never reached) must revert to
// its snapshot. Reverse-order processing makes stacked reverts converge
// on the oldest unmentioned snapshot, which is the last agent-confirmed
// state. Skip slots already corrected by a later command.
//
if cmd.State != "Success" {
for key, snap := range snapBySlot {
Expand All @@ -1021,7 +1024,7 @@ func correctModelFromCommandOutcome(t *testing.T, cmd *apimodel.Command, model *
continue
}
res := model.Resource(key.stackIdx, key.slotIdx)
if res == nil || res.State == snap.State {
if res == nil || (res.State == snap.State && res.Properties == snap.Properties) {
continue
}
t.Logf("correctModelFromCommandOutcome: reverting unmentioned slot stack=%s slot=%d from %v to %v (command state=%s)",
Expand Down
31 changes: 31 additions & 0 deletions tests/blackbox/state_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,3 +720,34 @@ func TestCorrectModelFromCommandOutcome_TTLSupersededSlotStaysDestroyed(t *testi
require.True(t, model.IsAuthoritativeSlot(2, xslot),
"the TTL destroy's authoritative mark must survive the stale correction")
}

// A failed command's unmentioned slot was never touched by the agent, so an
// optimistic property prediction for it must roll back even when the slot's
// State never changed. A patch that fails on a sibling before reaching the
// slot would otherwise leave the model expecting merged properties the
// agent never wrote.
func TestCorrectModelFromCommandOutcome_UnmentionedSlotRevertsProperties(t *testing.T) {
model := NewStateModel(1, 3)
model.ApplyCreated(0, []int{1}, `{"Value":"v1"}`)

// Snapshot at command submission, then the optimistic patch prediction.
snapshots := []ResourceSnapshot{{StackIndex: 0, SlotIndex: 1, State: StateExists, Properties: `{"Value":"v1"}`}}
model.Resource(0, 1).Properties = `{"Value":"v2"}` // optimistic merge

cmd := &apimodel.Command{
CommandID: "cmd-failed-before-slot",
State: "Failed",
ResourceUpdates: []apimodel.ResourceUpdate{{
StackName: "stack-0",
ResourceLabel: "res-stack-0-c", // slot 2 — the failing sibling
Operation: "create",
State: "Failed",
}},
}
corrected := map[struct{ stackIdx, slotIdx int }]bool{}
correctModelFromCommandOutcome(t, cmd, model, nil, snapshots, corrected, false, nil)

require.Equal(t, StateExists, model.Resource(0, 1).State)
require.Equal(t, `{"Value":"v1"}`, model.Resource(0, 1).Properties,
"optimistic properties must revert to the snapshot when the failed command never touched the slot")
}
Loading