Say a weekly upgrade that hangs past two hours, so a stuck pacman lock is loud (issue 1574, ADR 0477)
mesh/merge-gate head bd10ec4f0dbe merged into trunk 4bdacf38d2f0 — pass: builds pacman → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head bd10ec4f0dbe merged into trunk 4bdacf38d2f0 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

This commit is contained in:
2026-10-21 08:08:04 +00:00
parent d4d1a1591b
commit bd10ec4f0d
3 changed files with 59 additions and 7 deletions
+7 -3
View File
@@ -33,9 +33,13 @@ that seat (ADR 0207).
- **The outcome is the module's health.** The timer's resource asks `pacman_upgrade_health`: unhealthy, - **The outcome is the module's health.** The timer's resource asks `pacman_upgrade_health`: unhealthy,
naming the exit and the last `error:` line of the journal, when the last weekly upgrade failed; healthy naming the exit and the last `error:` line of the journal, when the last weekly upgrade failed; healthy
while it runs, after it passed, and before it ever ran. The controller raises the module's condition for while it runs, after it passed, and before it ever ran. The controller raises the module's condition for
the node from it. A failed service stays failed until the next run, so the condition stands until an the node from it. A failed service stays failed until
upgrade passes, a hand `pacman_upgrade` does not clear it. A restart of the machine forgets the failed the next run, so the condition stands until an upgrade passes; a hand `pacman_upgrade` does not clear
state (the service manager's), so it is the condition raised before it that keeps the record. it. A restart of the machine clears the failed state and with it the condition, and the next Saturday
runs again. A run that goes on longer than two hours is unhealthy too, naming when it started and the
last line of its journal: it is hung and holds pacman's database lock. The service has no start
timeout on purpose, because killing pacman in the middle of a transaction is worse; the hung run is
said, not killed.
## What it improves ## What it improves
+21 -2
View File
@@ -378,8 +378,9 @@ func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) {
// The weekly upgrade's outcome is the module's health (issue 1574, ADR 0477 clause 5). // The weekly upgrade's outcome is the module's health (issue 1574, ADR 0477 clause 5).
func upgradeHealthMachine(props, journal string) *Machine { func upgradeHealthMachine(props, journal string) *Machine {
return machine(byLine(map[string]Ran{ return machine(byLine(map[string]Ran{
"systemctl show " + WeeklyUnit + " --no-pager --property=LoadState --property=ActiveState --property=Result --property=ExecMainStatus --property=ExecMainExitTimestamp": {Stdout: props}, "systemctl show " + WeeklyUnit + " --no-pager --property=LoadState --property=ActiveState --property=Result --property=ExecMainStatus --property=ExecMainExitTimestamp --property=ExecMainStartTimestamp": {Stdout: props},
"sudo -n journalctl --no-pager -o cat -n 40 -u " + WeeklyUnit: {Stdout: journal}, "sudo -n journalctl --no-pager -o cat -n 40 -u " + WeeklyUnit: {Stdout: journal},
"sudo -n journalctl --no-pager -o cat -n 1 -u " + WeeklyUnit: {Stdout: journal},
}, nil), 1000) }, nil), 1000)
} }
@@ -398,7 +399,7 @@ func TestAFailedWeeklyUpgradeIsUnhealthyNamingTheError(t *testing.T) {
func TestAPassedRunningOrNeverRunWeeklyUpgradeIsHealthy(t *testing.T) { func TestAPassedRunningOrNeverRunWeeklyUpgradeIsHealthy(t *testing.T) {
for name, props := range map[string]string{ for name, props := range map[string]string{
"passed": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=Sat 2026-10-24 04:09:00 CEST\n", "passed": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=Sat 2026-10-24 04:09:00 CEST\n",
"running": "LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\n", "running": "LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\nExecMainStartTimestamp=Wed 2026-10-04 11:50:00 UTC\n",
"never run": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\n", "never run": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\n",
} { } {
if h := upgradeHealthMachine(props, "").UpgradeHealth(); !h.Healthy { if h := upgradeHealthMachine(props, "").UpgradeHealth(); !h.Healthy {
@@ -409,3 +410,21 @@ func TestAPassedRunningOrNeverRunWeeklyUpgradeIsHealthy(t *testing.T) {
t.Errorf("a unit that cannot be read is not healthy: %+v", h) t.Errorf("a unit that cannot be read is not healthy: %+v", h)
} }
} }
// A run that goes on past RunLimit is hung and holds pacman's lock: unhealthy, naming its start (issue 1574).
func TestAHungWeeklyUpgradeIsUnhealthyNamingItsStart(t *testing.T) {
// the test machine's clock is 2026-10-04 12:00 UTC
h := upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=Wed 2026-10-04 09:00:00 UTC\n",
"downloading linux-6.99...\n").UpgradeHealth()
if h.Healthy || !strings.Contains(h.Why, "Wed 2026-10-04 09:00:00 UTC") || !strings.Contains(h.Why, "downloading linux-6.99") {
t.Fatalf("started 3 h ago: %+v", h)
}
h = upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=Wed 2026-10-04 11:50:00 UTC\n", "").UpgradeHealth()
if !h.Healthy {
t.Fatalf("started 10 min ago: %+v", h)
}
h = upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=\n", "").UpgradeHealth()
if h.Healthy {
t.Fatalf("a start that cannot be read is not healthy: %+v", h)
}
}
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
) )
// The weekly upgrade's outcome (issue 1574, ADR 0477 clause 5). The mesh runs a full upgrade on every node // The weekly upgrade's outcome (issue 1574, ADR 0477 clause 5). The mesh runs a full upgrade on every node
@@ -22,11 +23,20 @@ type Health struct {
// WeeklyUnit is the service the weekly timer starts. // WeeklyUnit is the service the weekly timer starts.
const WeeklyUnit = "mesh-pacman-weekly-upgrade.service" const WeeklyUnit = "mesh-pacman-weekly-upgrade.service"
// RunLimit is how long a weekly upgrade may go on before it is unhealthy. The service has no start timeout on
// purpose: killing pacman in the middle of a transaction leaves a half-upgraded machine. A run past the limit
// is a hung one (a stuck hook, a stalled transaction), which holds pacman's database lock so that no later run
// can start; it is said, not killed.
const RunLimit = 2 * time.Hour
// systemdTime is how systemctl shows a timestamp, in the machine's local time.
const systemdTime = "Mon 2006-01-02 15:04:05 MST"
// UpgradeHealth judges the last weekly upgrade from its service: unhealthy when it failed, naming the exit // UpgradeHealth judges the last weekly upgrade from its service: unhealthy when it failed, naming the exit
// and the last error the journal holds; healthy while it runs, after it passed, and before it ever ran. A // and the last error the journal holds; healthy while it runs, after it passed, and before it ever ran. A
// unit that cannot be read is unhealthy: failure is loud. // unit that cannot be read is unhealthy: failure is loud.
func (m *Machine) UpgradeHealth() Health { func (m *Machine) UpgradeHealth() Health {
p, err := m.unitProps(WeeklyUnit, "LoadState", "ActiveState", "Result", "ExecMainStatus", "ExecMainExitTimestamp") p, err := m.unitProps(WeeklyUnit, "LoadState", "ActiveState", "Result", "ExecMainStatus", "ExecMainExitTimestamp", "ExecMainStartTimestamp")
if err != nil { if err != nil {
return Health{Why: "the weekly upgrade's service could not be read: " + err.Error()} return Health{Why: "the weekly upgrade's service could not be read: " + err.Error()}
} }
@@ -34,7 +44,7 @@ func (m *Machine) UpgradeHealth() Health {
case p["LoadState"] == "not-found": case p["LoadState"] == "not-found":
return Health{Healthy: true, Why: "the weekly upgrade's service is not placed yet; no upgrade has run"} return Health{Healthy: true, Why: "the weekly upgrade's service is not placed yet; no upgrade has run"}
case p["ActiveState"] == "activating" || p["ActiveState"] == "active": case p["ActiveState"] == "activating" || p["ActiveState"] == "active":
return Health{Healthy: true, Why: "the weekly upgrade is running"} return m.runningHealth(p["ExecMainStartTimestamp"])
case p["ActiveState"] != "failed" && p["Result"] == "success": case p["ActiveState"] != "failed" && p["Result"] == "success":
if at := p["ExecMainExitTimestamp"]; at != "" { if at := p["ExecMainExitTimestamp"]; at != "" {
return Health{Healthy: true, Why: "the last weekly upgrade passed (" + at + ")"} return Health{Healthy: true, Why: "the last weekly upgrade passed (" + at + ")"}
@@ -53,6 +63,25 @@ func (m *Machine) UpgradeHealth() Health {
return Health{Why: why + "; the next upgrade is the next Saturday 04:00, or a hand pacman_upgrade", Found: found} return Health{Why: why + "; the next upgrade is the next Saturday 04:00, or a hand pacman_upgrade", Found: found}
} }
// runningHealth judges a run in progress: healthy within RunLimit of its start, unhealthy past it, naming the
// start and the last line the journal holds.
func (m *Machine) runningHealth(started string) Health {
at, err := time.ParseInLocation(systemdTime, started, time.Local)
if err != nil {
return Health{Why: fmt.Sprintf("the weekly upgrade is running and when it started could not be read (%q)", started)}
}
if m.Now().Sub(at) <= RunLimit {
return Health{Healthy: true, Why: "the weekly upgrade is running, since " + started}
}
why := fmt.Sprintf("the weekly upgrade has been running since %s, longer than %s: it is hung, and holds pacman's database lock so no later upgrade can start", started, RunLimit)
found := []string{}
if ls := m.journal(WeeklyUnit, 1); len(ls) > 0 && !strings.HasPrefix(ls[0], "(the journal could not be read") {
why += "; its last line: " + strings.TrimSpace(ls[0])
found = append(found, strings.TrimSpace(ls[0]))
}
return Health{Why: why, Found: found}
}
// lastError is the last line pacman marked as an error, else the last line the journal holds; a journal that // lastError is the last line pacman marked as an error, else the last line the journal holds; a journal that
// could not be read says so in one line starting with "(", which is no error to name. // could not be read says so in one line starting with "(", which is no error to name.
func lastError(journal []string) string { func lastError(journal []string) string {