From 56b285d3e8a13189368b072079efe5b1af32e324 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 14:33:20 +0200 Subject: [PATCH] issue 1840/S7: prove updates is read at start, after a transaction, after the mirrors and daily, and written only on change A failed read keeps the last list and says reachable false, so a list the node can no longer confirm is never shown as current, and a first read that fails is never an empty list taken for an answer. --- .../pacman/cmd/pacman-tools/updates_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 modules/pacman/cmd/pacman-tools/updates_test.go diff --git a/modules/pacman/cmd/pacman-tools/updates_test.go b/modules/pacman/cmd/pacman-tools/updates_test.go new file mode 100644 index 00000000..6dc0e220 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/updates_test.go @@ -0,0 +1,147 @@ +package main + +// What an upgrade would change (novox/hq ADR 0507 ยง4; acceptance 1840/AC4): read at the keeper's start, after a +// transaction, after the mirror list is rewritten and a day after the last read; written only when the list or +// reachable changed; a failed read says reachable false and keeps the last list. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +func updatesOf(t *testing.T, s *fakeStore) UpdatesValue { + t.Helper() + e, _ := s.Get("node-a") + if e == nil { + t.Fatal("no updates.node-a") + } + var v UpdatesValue + if err := json.Unmarshal(e.Value, &v); err != nil { + t.Fatal(err) + } + return v +} + +func (d *pacmanDB) checkupdatesRuns() int { + d.mu.Lock() + defer d.mu.Unlock() + n := 0 + for _, c := range d.commands { + if c == "checkupdates" { + n++ + } + } + return n +} + +func TestUpdatesAreWrittenOnlyWhenTheListOrReachableChanged(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Stdout: "zsh 5.9-1 -> 5.9-2\nlinux 6.17.1-1 -> 6.17.2-1\n"}} + db.set("glibc 2.42-1\n", "") + k, states, _, _, _ := newTestKeeper(db) + up := states.store(StateUpdates) + k.refreshUpdates() + if w := up.Writes(); strings.Join(w, ",") != "put node-a" { + t.Fatalf("writes: %v", w) + } + if v := updatesOf(t, up); !v.Reachable || fmt.Sprint(v.Packages) != "[{linux 6.17.1-1 6.17.2-1} {zsh 5.9-1 5.9-2}]" { + t.Fatalf("%+v", v) + } + // the same answer: nothing written. + k.refreshUpdates() + if w := up.Writes(); len(w) != 0 { + t.Fatalf("the same list was written again: %v", w) + } + // the mirrors cannot be reached: reachable false, the last list kept. + db.setUpdates(Ran{Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}) + k.refreshUpdates() + if w := up.Writes(); len(w) != 1 { + t.Fatalf("writes: %v", w) + } + if v := updatesOf(t, up); v.Reachable || len(v.Packages) != 2 { + t.Fatalf("a failed read keeps the list and says reachable false: %+v", v) + } + k.refreshUpdates() + if w := up.Writes(); len(w) != 0 { + t.Fatalf("still unreachable, and written again: %v", w) + } + // reached again with nothing to do (status 2): an empty list, reachable. + db.setUpdates(Ran{Status: 2}) + k.refreshUpdates() + if v := updatesOf(t, up); !v.Reachable || v.Packages == nil || len(v.Packages) != 0 { + t.Fatalf("%+v", v) + } +} + +func TestAFirstReadThatFailsSaysUnreachableNeverAnEmptyListAsAnAnswer(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}} + k, states, _, _, _ := newTestKeeper(db) + k.refreshUpdates() + if v := updatesOf(t, states.store(StateUpdates)); v.Reachable { + t.Fatalf("%+v", v) + } + raw, _ := states.store(StateUpdates).Get("node-a") + if string(raw.Value) != `{"reachable":false,"packages":[]}` { + t.Errorf("%s", raw.Value) + } +} + +// Read at the start, after a transaction, after the mirror list is rewritten, and a day after the last read +// with nothing else asking; never in between. +func TestUpdatesAreReadAtStartAfterATransactionAfterTheMirrorsAndDaily(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, clock, w := newTestKeeper(db) + var mu sync.Mutex + var seen []string + k.Hooks.AfterTransaction = []func(*Keeper, Transaction) error{func(*Keeper, Transaction) error { + mu.Lock() + seen = append(seen, "transaction") + mu.Unlock() + return nil + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- k.Run(ctx) }() + reads := func(want int) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if db.checkupdatesRuns() == want { + return + } + } + t.Fatalf("checkupdates ran %d times, want %d", db.checkupdatesRuns(), want) + } + reads(1) + db.setUpdates(Ran{Stdout: "zsh 5.9-1 -> 5.9-2\n"}) + clock.Advance(time.Hour) + w.c <- MirrorsRewritten + reads(2) + db.setUpdates(Ran{Status: 2}) + w.c <- LockReleased + reads(3) + waitFor(t, &mu, &seen, "transaction") + clock.Advance(UpdatesEvery - time.Minute) + time.Sleep(50 * time.Millisecond) + if n := db.checkupdatesRuns(); n != 3 { + t.Fatalf("read before a day passed: %d", n) + } + db.setUpdates(Ran{Stdout: "glibc 2.42-1 -> 2.42-2\n"}) + clock.Advance(time.Minute) + reads(4) + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if e, _ := states.store(StateUpdates).Get("node-a"); e != nil && strings.Contains(string(e.Value), "glibc") { + break + } + } + if w := states.store(StateUpdates).Writes(); len(w) != 4 { + t.Errorf("each read that changed the list wrote it, and only those: %v", w) + } + cancel() + <-done +}