pacman keeps one mirror list for the mesh, ranked in the write that opens each round and written by every node (ADR 0585, issue 1876)

Each node's reflector ranked its own list on its own timer, and on 2026-10-31 one node's
lagging first mirror left it a Node.js version behind the rest. The list is now the
module's state mirrors, key list: the node that opens a week's round ranks it with the
module's reflector options, keeps only mirrors whose last sync is no older than the
newest found, and the round's key names the list's revision. Every keeper writes
/etc/pacman.d/mirrorlist whole from the key through the root helper's new verb
mirrorlist, when the key changes and when the file differs, and never writes an empty
or unreadable list. reflector.timer is declared stopped and disabled, and reflector.conf
saves nothing. pacman_mirrors answers the shared list, its ranker and age and whether
this node's file matches; with refresh it asks this node's keeper to rank again,
refused while a round runs, naming it. The round's integration test proves the opening
write and both nodes' files.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-01 16:18:22 +01:00
parent 37613ec6a9
commit 2b495c4818
19 changed files with 770 additions and 111 deletions
+84 -21
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -287,29 +288,91 @@ Server = https://mirror2.example.org/$repo/os/$arch
#Server = https://old.example.org/$repo/os/$arch
`
func TestMirrorsReadTheListAndRefreshWithoutWaiting(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
if c.name == "systemctl" && c.args[0] == "show" {
return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"}
}
return Ran{}
}, &calls), 1000)
m.ReadFile = func(p string) ([]byte, error) {
switch p {
case Mirrorlist:
return []byte(mirrorlistReflector), nil
case ReflectorConf:
return []byte("# comment\n--save /etc/pacman.d/mirrorlist\n--sort rate\n"), nil
}
return nil, errNoFile
// reflectorInfo is reflector's --info as it answered on 2026-11-01 (reflector 2023, its fields cut to those
// read and one more), the ranked order kept: two mirrors at the newest last sync, one behind, one with none.
const reflectorInfo = `https://nl.mirror.example/archlinux/$repo/os/$arch
active : True
country : Netherlands
last_sync : 2026-11-01 14:32:01 UTC
https://be.mirror.example/archlinux/$repo/os/$arch
active : True
last_sync : 2026-11-01 14:01:45 UTC
https://de.mirror.example/$repo/os/$arch
last_sync : 2026-11-01 14:32:01 UTC
https://fr.mirror.example/archlinux/$repo/os/$arch
last_sync : None
`
// ADR 0585 §2, §4: the ranking keeps only mirrors as fresh as the newest, in reflector's order, with the module's options
// and never its --save; a ranking that fails or keeps none is no list. pacman_mirrors answers the shared list, its
// ranker and age and whether this node's file matches it (the file as found when not); with refresh it asks this node's
// keeper to rank again, refused while a round runs, naming it.
func TestTheMeshsMirrorListIsRankedFreshAndAnsweredWithThisNodesFile(t *testing.T) {
conf := "# comment\n--save /etc/pacman.d/mirrorlist\n--country Belgium,Netherlands\n--sort rate\n"
if o := strings.Join(ReflectorOptions(conf), " "); o != "--country Belgium,Netherlands --sort rate" {
t.Errorf("the options are %q", o)
}
r, err := m.MirrorList(true)
if err != nil || len(r.Servers) != 2 || r.Commented != 1 || r.GeneratedBy != "reflector" || r.When != "2024-06-12 21:26:34 UTC" || len(r.Reflector) != 2 || !r.Refreshing {
t.Fatalf("%+v %v", r, err)
var asked []string
answer := Ran{Stdout: reflectorInfo}
saved := reflectorRun
reflectorRun = func(_ context.Context, args ...string) Ran { asked = args; return answer }
defer func() { reflectorRun = saved }()
l, err := RankMirrors([]byte(conf), "node-a", t0, "2026-W43")
if err != nil || strings.Join(l.Servers, " ") != "https://nl.mirror.example/archlinux/$repo/os/$arch https://de.mirror.example/$repo/os/$arch" ||
!l.Newest.Equal(time.Date(2026, 11, 1, 14, 32, 1, 0, time.UTC)) || l.RankedBy != "node-a" || l.Round != "2026-W43" ||
strings.Join(asked, " ") != "--country Belgium,Netherlands --sort rate --info" {
t.Fatalf("ranked %+v (%v), reflector asked %v", l, err, asked)
}
if calls[len(calls)-1].String() != "sudo -n systemctl start --no-block reflector.service" {
t.Fatalf("%v", calls[len(calls)-1])
for _, a := range []Ran{{Status: 1, Stderr: "error: no mirrors"}, {Stdout: "https://x.example/$repo/os/$arch\nlast_sync : None\n"}} {
answer = a
if _, err := RankMirrors([]byte(conf), "node-a", t0, ""); err == nil {
t.Errorf("a ranking from %+v is a list", a)
}
}
if checkServers([]string{"https://ok.example/$repo/os/$arch\nServer = https://evil.example/$repo/os/$arch"}) == nil {
t.Error("a server carrying a second line is taken")
}
states := &fakeStates{}
states.store(StateMirrors).Put(MirrorsKey, l)
file := RenderMirrorlist(l.Servers)
m := machine(func(context.Context, string, ...string) Ran {
return Ran{Stdout: "LoadState=loaded\nUnitFileState=disabled\nActiveState=inactive\n"}
}, 1000)
m.Node, m.States = "node-a", readerOf(states)
m.Now = func() time.Time { return t0.Add(90 * time.Minute) }
m.ReadFile = func(string) ([]byte, error) { return file, nil }
v, err := m.MirrorsView(false)
if err != nil || v["file_matches"] != true || v["ranked_by"] != "node-a" || v["age"] != "1h30m0s" || v["file"] != nil ||
v["reflector_timer"].(map[string]string)["UnitFileState"] != "disabled" {
t.Fatalf("the view is %v (%v)", v, err)
}
file = []byte(mirrorlistReflector)
if v, _ := m.MirrorsView(false); v["file_matches"] != false || v["file"].(Mirrors).GeneratedBy != "reflector" {
t.Errorf("a file other than the list is %v", v)
}
rounds, runs, asks := newBucket(), newBucket(), newBucket()
savedState, savedNode := upgradeState, thisNode
upgradeState = func(name string) Bucket {
return map[string]Bucket{StateRounds: rounds, StateRuns: runs, StateAsks: asks}[name]
}
thisNode = func() string { return "node-a" }
defer func() { upgradeState, thisNode = savedState, savedNode }()
rounds.Put("2026-W43", Round{Round: "2026-W43", Opened: t0, OpenedBy: "node-a", Order: []string{"node-a", "node-b"}})
if _, err := m.MirrorsView(true); err == nil || !strings.Contains(err.Error(), "round 2026-W43 runs") {
t.Errorf("a refresh during a round: %v", err)
}
if keys, _ := asks.Keys(); len(keys) != 0 {
t.Errorf("a refused refresh asked %v", keys)
}
rounds.Delete("2026-W43")
v, err = m.MirrorsView(true)
if keys, _ := asks.Keys(); err != nil || len(keys) != 1 || !strings.HasPrefix(keys[0], "node-a.") || v["asked"] == nil {
t.Errorf("a refresh asked %v (%v): %v", keys, err, v)
}
}