Keep a no-holder machine as ADR 0404 says; prove the cut takes the backups before its first tier (issue 1086, review round 1)
mesh/merge-gate head b6f8aef4d4f1 merged into trunk 47f9290b37aa — pass: builds mesh-controller → novox; no bus step; every machine composes with the chang…
mesh/repo-check head b6f8aef4d4f1 merged into trunk 47f9290b37aa — pass: its merge-check.sh passed
mesh/delivery delivered

This commit is contained in:
2026-10-18 01:57:00 +02:00
parent 7103e33294
commit b6f8aef4d4
3 changed files with 98 additions and 32 deletions
@@ -4,6 +4,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"os"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -217,3 +218,91 @@ func TestADeliveryThatMovesABackedUpModuleWaitsForItsBackupAndAFailedOneHoldsItA
t.Fatalf("the condition stays: %+v", said) t.Fatalf("the condition stays: %+v", said)
} }
} }
// Issue 1086: a plan cut from a merge asked its first tier at the cut, so a module keeping data was sent without the
// backup the plan promised. The cut now takes the backups first (askFirstTierAtCut, batches.go), and the walk's own
// step asks the tier once they are done.
func TestAPlanCutFromAMergeOfABackedUpModuleAsksItsBackupAndNotItsTierUntilItIsDone(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
settings := t.TempDir() + "/merge-window.json"
if err := os.WriteFile(settings, []byte(`{"merge-window": "90s", "merge-window-at-most": "10m"}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(mergeWindowFileVar, settings)
everyHeadWaits(t)
asked := asksRecorded(t)
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
before := handActConn
handActConn = js.Conn()
t.Cleanup(func() { handActConn = before })
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "keeper", Version: "1",
Claims: []catalogue.Claim{{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode, Serves: []string{"backed-up", "now", "restore"}}}})
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"},
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
notes, err := catalogue.ParseManifest([]byte(`{"module":"notes","version":"1","data":{"own":[` +
`{"id":"files","path":"${dir:files}","class":"valuable","why":"people's notes"}]},` +
`"resources":[{"id":"files","type":"directory"}]}`))
if err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, notes, inventory.Source{Repository: "novox/notes", Seat: "git", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
for _, m := range []string{"keeper", "notes"} {
if _, err := assign(ctx, open, "anchor", m); err != nil {
t.Fatal(err)
}
}
seat := &aBackupSeatOnTheBus{}
seat.serve(t, js.Conn(), "anchor")
wasWithin, wasEvery := backupsWithin, backupsPollEvery
backupsWithin, backupsPollEvery = time.Minute, 10*time.Millisecond
t.Cleanup(func() { backupsWithin, backupsPollEvery = wasWithin, wasEvery })
// 1. A merge of notes is heard and its window closes: the batch is cut through cutBatch.
hear(t, open, repoMerge("notes", "c-notes", t0), t0.Add(time.Second))
cutAt(t, open, t0.Add(time.Second+90*time.Second))
ws, _ := walks(t, open)
if len(ws) != 1 {
t.Fatalf("%d walk(s)", len(ws))
}
// 2. Right after the cut no tier is asked, the record has anchor/notes pending, the seat heard one `now`.
cut, err := inv.PlanByID(ctx, ws[0].ID)
if err != nil {
t.Fatal(err)
}
if len(*asked) != 0 || askedInTier(cut) {
t.Fatalf("the first tier was asked at the cut: %v %+v", *asked, cut.Modules)
}
if cut.Backups == nil || cut.Backups.State != inventory.BackupPending || len(cut.Backups.Asked) != 1 ||
cut.Backups.Asked[0].Machine != "anchor" || cut.Backups.Asked[0].Module != "notes" {
t.Fatalf("the backups of the cut: %+v", cut.Backups)
}
if seat.asks() != 1 {
t.Fatalf("the seat heard %d `now`", seat.asks())
}
// 3. The backup answers done: the walk's own step asks the first tier, once, and asks no second backup.
seat.answers("notes", true, "")
advancePlans(ctx, open)
advancePlans(ctx, open)
after, err := inv.PlanByID(ctx, ws[0].ID)
if err != nil {
t.Fatal(err)
}
if len(*asked) != 1 || after.Backups.State != inventory.BackupDone || seat.asks() != 1 {
t.Fatalf("builds=%v backups=%+v asks=%d", *asked, after.Backups, seat.asks())
}
}
+1 -6
View File
@@ -240,7 +240,7 @@ func stepBackupsOnce(ctx context.Context, p *inventory.Plan, facts backupFacts,
} }
// wantedBackups is the record of backups a plan has to take, or nil for none: one entry per machine with a holder // wantedBackups is the record of backups a plan has to take, or nil for none: one entry per machine with a holder
// running a moved module that has a backed-up item; a machine with no holder is a failed entry that holds the plan. // running a moved module that has a backed-up item, and the machines with none.
func wantedBackups(p inventory.Plan, facts backupFacts) *inventory.PlanBackups { func wantedBackups(p inventory.Plan, facts backupFacts) *inventory.PlanBackups {
var asked []inventory.PlanBackup var asked []inventory.PlanBackup
noHolder := map[string]bool{} noHolder := map[string]bool{}
@@ -248,12 +248,7 @@ func wantedBackups(p inventory.Plan, facts backupFacts) *inventory.PlanBackups {
for _, module := range tier { for _, module := range tier {
for _, machine := range facts.backedUp[module] { for _, machine := range facts.backedUp[module] {
if !facts.holder[machine] { if !facts.holder[machine] {
// A machine the plan sends data to and cannot ask holds the plan (issue 1086): a send never
// goes on without the restore point it promised.
noHolder[machine] = true noHolder[machine] = true
asked = append(asked, inventory.PlanBackup{Machine: machine, Module: module,
State: inventory.BackupFailed, Words: "could not be asked: " + machine + " holds no " +
catalogue.BackupSeat})
continue continue
} }
asked = append(asked, inventory.PlanBackup{Machine: machine, Module: module, State: inventory.BackupPending}) asked = append(asked, inventory.PlanBackup{Machine: machine, Module: module, State: inventory.BackupPending})
+8 -26
View File
@@ -111,8 +111,8 @@ func TestOnlyModulesWithAnItemProtectedByABackupAreAsked(t *testing.T) {
} }
} }
// A machine with no backup holder cannot be asked: the plan holds and says so, and asks nothing (issue 1086). // A machine with no backup holder is asked nothing, the plan says so, and it does not hold the plan.
func TestANodeWithNoBackupHolderHoldsThePlanAndSaysSo(t *testing.T) { func TestANodeWithNoBackupHolderAsksNothingAndThePlanSaysSo(t *testing.T) {
seat := &aFakeBackupSeat{} seat := &aFakeBackupSeat{}
seat.install(t) seat.install(t)
entries := []inventory.Entry{aModuleWith(t, "copied", []string{"ace", "bare"}, anItem("a", ""))} entries := []inventory.Entry{aModuleWith(t, "copied", []string{"ace", "bare"}, anItem("a", ""))}
@@ -121,17 +121,17 @@ func TestANodeWithNoBackupHolderHoldsThePlanAndSaysSo(t *testing.T) {
if err != nil || proceed { if err != nil || proceed {
t.Fatalf("proceed=%v err=%v", proceed, err) t.Fatalf("proceed=%v err=%v", proceed, err)
} }
if len(seat.asked) != 0 { if strings.Join(seat.asked, " ") != "ace/copied" {
t.Fatalf("a held plan asks nothing: %v", seat.asked) t.Fatalf("asked %v", seat.asked)
} }
if p.Backups == nil || len(p.Backups.NoHolder) != 1 || p.Backups.NoHolder[0] != "bare" || if p.Backups == nil || len(p.Backups.NoHolder) != 1 || p.Backups.NoHolder[0] != "bare" {
p.Backups.State != inventory.BackupFailed || !strings.Contains(p.Note, "copied on bare") { t.Fatalf("the plan must say bare has no holder: %+v", p.Backups)
t.Fatalf("the plan must hold and say bare has no holder: %+v note %q", p.Backups, p.Note)
} }
// Only the no-holder machine: nothing to wait for, the plan goes on, and still says it.
only := []inventory.Entry{aModuleWith(t, "copied", []string{"bare"}, anItem("a", ""))} only := []inventory.Entry{aModuleWith(t, "copied", []string{"bare"}, anItem("a", ""))}
q := aPlanOf("copied") q := aPlanOf("copied")
proceed, err = stepBackups(t.Context(), q, backupFactsFrom(only, holders("ace")), backupT0) proceed, err = stepBackups(t.Context(), q, backupFactsFrom(only, holders("ace")), backupT0)
if err != nil || proceed || q.Backups.State != inventory.BackupFailed { if err != nil || !proceed || q.Backups == nil || q.Backups.NoHolder[0] != "bare" || q.Backups.State != inventory.BackupDone {
t.Fatalf("proceed=%v err=%v record=%+v", proceed, err, q.Backups) t.Fatalf("proceed=%v err=%v record=%+v", proceed, err, q.Backups)
} }
} }
@@ -264,24 +264,6 @@ func TestACutPlanAsksTheBackupOfGiteaOnNovoxBeforeItAsksItsFirstTier(t *testing.
} }
} }
func TestACutPlanHoldsWhenTheMachineItSendsDataToHasNoBackupHolder(t *testing.T) {
seat := &aFakeBackupSeat{}
seat.install(t)
facts := backupFactsFrom([]inventory.Entry{giteaOnNovox(t)}, holders("ace"))
p := aPlanOf("gitea")
tierAsked := false
err := askFirstTierAtCut(p,
func(p *inventory.Plan) (bool, error) { return stepBackups(t.Context(), p, facts, backupT0) },
func() error { tierAsked = true; return nil })
if err != nil || tierAsked {
t.Fatalf("asked=%v err=%v", tierAsked, err)
}
if p.Backups == nil || p.Backups.State != inventory.BackupFailed || !strings.Contains(p.Note, "novox") ||
!strings.Contains(p.Note, "held") {
t.Fatalf("the plan must hold and say so: %+v note %q", p.Backups, p.Note)
}
}
func TestACutPlanWhoseBackupsCouldNotBeWorkedOutAsksNothingAndSaysSo(t *testing.T) { func TestACutPlanWhoseBackupsCouldNotBeWorkedOutAsksNothingAndSaysSo(t *testing.T) {
p := aPlanOf("gitea") p := aPlanOf("gitea")
tierAsked := false tierAsked := false