fix(volition): intention and plan transitions are compare-and-set
Apply and Supersede updated an intention by id alone, so a reconsideration from the event bus and one from the executor could both apply: an intention abandoned on goal_abandoned could be resumed by resumeParent a moment later, leaving it active with closed_at set. Each transition now writes only when the status is still the one the caller read, before its event and decision, and a lost race returns ErrClosed having written nothing. Plan SetStatus likewise changes only an open plan or a draft, so a stale copy can never relabel a completed, failed, abandoned or superseded plan. Regression tests cover both.
This commit is contained in:
@@ -591,3 +591,28 @@ func TestConcurrentReconsiderationReplansOnce(t *testing.T) {
|
||||
t.Fatalf("want one replan, got %d plans", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a caller holding a stale copy of a plan (a reconsideration
|
||||
// that lost a race with completion) must not relabel a closed plan.
|
||||
func TestClosedPlanIsNeverRelabelled(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.host.fixOnRestart = true
|
||||
f.allow("service.restart")
|
||||
it := f.intention(false)
|
||||
p := f.start(it)
|
||||
for i := 0; i < 5 && f.plan(p.ID).Status != PlanCompleted; i++ {
|
||||
f.tick()
|
||||
}
|
||||
if f.plan(p.ID).Status != PlanCompleted {
|
||||
t.Fatalf("plan did not complete: %s", f.plan(p.ID).Status)
|
||||
}
|
||||
if err := f.plans.SetStatus(f.ctx, nil, p.ID, PlanBlocked, "stale reconsideration"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.exec.Reconsider(f.ctx, p.ID, volition.Trigger{Kind: volition.TriggerResourceExhausted, Detail: "late"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.plan(p.ID); got.Status != PlanCompleted || got.StatusReason == "stale reconsideration" {
|
||||
t.Fatalf("closed plan relabelled: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +385,10 @@ func (s *Store) SaveIf(ctx context.Context, st Step, expected string) (bool, err
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// SetStatus writes a plan's status and reason.
|
||||
// SetStatus writes a plan's status and reason. Only an open plan (or a
|
||||
// draft) changes: a plan that was completed, failed, abandoned or
|
||||
// superseded meanwhile keeps that state, so a caller holding a stale copy
|
||||
// can never reopen or relabel a closed plan.
|
||||
func (s *Store) SetStatus(ctx context.Context, q storage.Querier, id ids.ID, status, reason string) error {
|
||||
if q == nil {
|
||||
q = s.db
|
||||
@@ -395,7 +398,8 @@ func (s *Store) SetStatus(ctx context.Context, q storage.Querier, id ids.ID, sta
|
||||
if !PlanOpen(status) && status != PlanDraft {
|
||||
closed = &now
|
||||
}
|
||||
_, err := q.Exec(ctx, `UPDATE plans SET status=$2, status_reason=$3, closed_at=coalesce($4, closed_at), updated_at=$5 WHERE id=$1`, id, status, trunc(reason, 500), closed, now)
|
||||
_, err := q.Exec(ctx, `UPDATE plans SET status=$2, status_reason=$3, closed_at=coalesce($4, closed_at), updated_at=$5 WHERE id=$1 AND status = ANY($6)`,
|
||||
id, status, trunc(reason, 500), closed, now, []string{PlanDraft, PlanValidated, PlanWaiting, PlanExecuting, PlanBlocked, PlanAwaitingApproval, PlanReplanning})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user