diff --git a/cmd/mesh-controller/plan_failed_test.go b/cmd/mesh-controller/plan_failed_test.go index 535ec2e7..05691840 100644 --- a/cmd/mesh-controller/plan_failed_test.go +++ b/cmd/mesh-controller/plan_failed_test.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "sort" "strings" "testing" "time" @@ -251,3 +254,85 @@ func TestAPassedFailedPlanHasNoActionAndSaysItWasPassed(t *testing.T) { t.Errorf("not plain: %s\n%+v", why, words) } } + +// plansBehind serves plans as the inventory does: the open ones, the most recent n, and every one made since. +type plansBehind []inventory.Plan + +func (s plansBehind) OpenPlans(context.Context) ([]inventory.Plan, error) { + var out []inventory.Plan + for _, p := range s { + if p.Open() { + out = append(out, p) + } + } + return out, nil +} + +func (s plansBehind) RecentPlans(_ context.Context, limit int) ([]inventory.Plan, error) { + all := append([]inventory.Plan(nil), s...) + sort.SliceStable(all, func(i, j int) bool { return all[i].Created.After(all[j].Created) }) + if len(all) > limit { + all = all[:limit] + } + return all, nil +} + +func (s plansBehind) PlansSince(_ context.Context, since time.Time) ([]inventory.Plan, error) { + var out []inventory.Plan + for _, p := range s { + if p.Created.After(since) || p.Open() { + out = append(out, p) + } + } + return out, nil +} + +// 1328, review round 3: the plan that passed a failed one is seen by `plans retry` however many plans were made after it. +func TestARetrySeesThePassingPlanBeyondTheFiftyMostRecent(t *testing.T) { + failed := aFailedPlan("plan-1", func(p *inventory.Plan) { p.Branch, p.Created = "main", time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) }) + all := plansBehind{failed, laterPlan("plan-done", "main", inventory.PlanDone, failed)} + for i := 0; i < 60; i++ { // 60 newer plans of other repositories push the passing one out of the newest 50 + other := laterPlan(fmt.Sprintf("plan-x%d", i), "main", inventory.PlanFailed, failed) + other.Repository = "novox/other" + other.Created = failed.Created.Add(time.Duration(48+i) * time.Hour) + all = append(all, other) + } + plans, err := plansForRetry(t.Context(), all, failed) + if err != nil { + t.Fatal(err) + } + if err := retryRefusal(failed, plans, map[string]string{}); err == nil || !strings.Contains(err.Error(), "plan-done") { + t.Fatalf("the retry was not refused naming the passing plan beyond the 50 most recent: %v", err) + } +} + +// ADR 0451 §3: a failed plan that last changed 8 days ago raises nothing, and a raised condition ends once its +// plan's last change passes 7 days. +func TestAFailedPlanLastChangedEightDaysAgoRaisesNothingAndARaisedOneEnds(t *testing.T) { + now := time.Date(2026, 10, 19, 18, 30, 0, 0, time.UTC) + old := aFailedPlan("plan-1", func(p *inventory.Plan) { p.Updated = now.Add(-8 * 24 * time.Hour) }) + f := calm(now) + f.failed = []failedPlanFacts{failedFactsFor(old)} + if got := watchPlansFailed(f); len(got) != 0 { + t.Fatalf("a plan last changed 8 days ago raised %+v", got) + } + store := conditions.NewInMemory() + k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, + Teller: &conditions.Told{}, Now: func() time.Time { return now }}) + defer k.Close(t.Context()) + fresh := aFailedPlan("plan-1", func(p *inventory.Plan) { p.Updated = now.Add(-6 * 24 * time.Hour) }) + f.failed = []failedPlanFacts{failedFactsFor(fresh)} + if err := k.Reconcile(t.Context(), "S23", watchPlansFailed(f)); err != nil { + t.Fatal(err) + } + if open, _ := k.Open(t.Context()); len(open) != 1 { + t.Fatalf("a plan last changed 6 days ago is not raised: %+v", open) + } + f.now = now.Add(2 * 24 * time.Hour) // its last change is now 8 days old + if err := k.Reconcile(t.Context(), "S23", watchPlansFailed(f)); err != nil { + t.Fatal(err) + } + if open, _ := k.Open(t.Context()); len(open) != 0 { + t.Fatalf("the condition outlived the 7 days: %+v", open) + } +} diff --git a/cmd/mesh-controller/plan_retry.go b/cmd/mesh-controller/plan_retry.go index e3b79760..6e5e99ed 100644 --- a/cmd/mesh-controller/plan_retry.go +++ b/cmd/mesh-controller/plan_retry.go @@ -305,6 +305,32 @@ func goesOnAgain(p *inventory.Plan) { } } +// planReader is what a retry reads the mesh's plans through. +type planReader interface { + OpenPlans(ctx context.Context) ([]inventory.Plan, error) + RecentPlans(ctx context.Context, limit int) ([]inventory.Plan, error) + PlansSince(ctx context.Context, since time.Time) ([]inventory.Plan, error) +} + +// plansForRetry are the plans a retry of p judges itself against. +func plansForRetry(ctx context.Context, inv planReader, p inventory.Plan) ([]inventory.Plan, error) { + plans, err := inv.OpenPlans(ctx) + if err != nil { + return nil, err + } + recent, err := inv.RecentPlans(ctx, 50) + if err != nil { + return nil, err + } + // **Every plan made after this one** (novox/hq ADR 0451 §1): a plan that passed it is seen however many plans + // were made since, not only when it is among the newest. + later, err := inv.PlansSince(ctx, p.Created) + if err != nil { + return nil, err + } + return append(append(plans, recent...), later...), nil +} + // retryPlan asks a failed plan's failed modules again, under new ids, and sets it building again at // that tier: what follows is the plan going on as if they had built the first time. func retryPlan(ctx context.Context, open *stores, id string) (string, error) { @@ -318,15 +344,10 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) { if err != nil { return "", err } - plans, err := inv.OpenPlans(ctx) + plans, err := plansForRetry(ctx, inv, p) if err != nil { return "", err } - recent, err := inv.RecentPlans(ctx, 50) - if err != nil { - return "", err - } - plans = append(plans, recent...) // **A failed walk whose earlier merges are walked alone is not retried** (novox/hq ADR 0276): the search for // the merge that brought the failure answers them now, and a retried walk would name them twice. // **A walk stopped without some merges is not retried** (novox/hq ADR 0299): the merges it left out were stopped,