fix(dbbackup): 备份新鲜度按最新的 daily 包算,手动或异地包不再掩盖停掉的定时任务

This commit is contained in:
Lemon-miaow committed 2026-09-27 16:40:50 +08:00
1 parent 46f99a7104
commit 840d339760
18 files changed
+262 -59

No files matched your search

+10 -3
View File
@@ -16,8 +16,8 @@ import (
// something on this install right now".
// dbBackupView is the wire shape. Last is null until the first backup has been
// recorded; Stale is true for a missing record too, so the panel has a single
// flag for "nobody could restore today's state".
// recorded; Stale is true for a missing record or daily backup too, so the
// panel has a single flag for "the daily backups have stopped".
type dbBackupView struct {
Last *dbbackup.Status `json:"last"`
Stale bool `json:"stale"`
@@ -43,7 +43,14 @@ func (a *API) handleGetDBBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
// Staleness goes by the daily timer's newest bundle: a manual or
// pre-migrate one recorded since would hide a timer that has stopped. A
// daily record is its own daily bundle, also when written before daily_at
// existed. No daily bundle leaves the zero time, centuries past the limit.
if st.Label == dbbackup.LabelDaily {
st.DailyAt = st.At
}
view.Last = &st
view.Stale = st.At.IsZero() || a.now().Sub(st.At) > dbbackup.StaleAfter
view.Stale = a.now().Sub(st.DailyAt) > dbbackup.StaleAfter
writeJSON(w, http.StatusOK, view)
}
+35
View File
@@ -69,6 +69,41 @@ func TestDBBackupFreshness(t *testing.T) {
}
}
// TestDBBackupDailyDecides: a fresh manual record says nothing about the
// timer, so the daily bundle it carries decides; a daily record from before
// daily_at existed is its own daily bundle.
func TestDBBackupDailyDecides(t *testing.T) {
now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
label string
dailyAge time.Duration // 0: no daily_at in the record
stale bool
wantDaily time.Time
}{
{"manual over a 30h-old daily", "manual", 30 * time.Hour, true, now.Add(-30 * time.Hour)},
{"manual over this morning's daily", "manual", 8 * time.Hour, false, now.Add(-8 * time.Hour)},
{"manual with no daily at all", "pre-migrate", 0, true, time.Time{}},
{"a daily record written before daily_at", "daily", 0, false, now.Add(-time.Hour)},
} {
t.Run(tc.name, func(t *testing.T) {
api, repo := seedUpdatesAPI(t)
api.Now = func() time.Time { return now }
st := dbbackup.Status{At: now.Add(-time.Hour), Name: "felis-db-x-" + tc.label + ".tar", Label: tc.label, Dir: "/var/lib/felis/db-backups"}
if tc.dailyAge > 0 {
st.DailyAt = now.Add(-tc.dailyAge)
}
raw, _ := json.Marshal(st)
repo.settings[dbbackup.StatusKey] = raw
code, v := getDBBackup(t, api)
if code != http.StatusOK || v.Last == nil || v.Stale != tc.stale || !v.Last.DailyAt.Equal(tc.wantDaily) || !v.Last.At.Equal(st.At) {
t.Fatalf("code %d, view %+v, want stale %v daily_at %s", code, v, tc.stale, tc.wantDaily)
}
})
}
}
func TestDBBackupStoreOutageIsAnError(t *testing.T) {
api, repo := seedUpdatesAPI(t)
repo.failGetSetting = errors.New("connection reset")
+5 -2
View File
@@ -17,7 +17,7 @@ import (
// response bodies. Each named schema is compared with the Go struct the handler
// actually encodes: the property set must equal the struct's JSON field set, and
// `required` must list exactly the fields that are always on the wire (no
// omitempty). The panel's types are checked against the same schemas at compile
// omitempty or omitzero). The panel's types are checked against the same schemas at compile
// time (panel/src/lib/types.parity.ts), so a field added here without the docs
// fails in Go, and one added to the docs without the panel fails in tsc.
func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
@@ -125,7 +125,10 @@ func wireFields(t reflect.Type) map[string]wireField {
if name == "" {
name = f.Name
}
out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type}
// omitzero leaves a field out too, and is the one that does for a
// struct such as time.Time.
opts = "," + opts + ","
out[name] = wireField{omitempty: strings.Contains(opts, ",omitempty,") || strings.Contains(opts, ",omitzero,"), typ: f.Type}
}
return out
}
+38 -13
View File
@@ -157,9 +157,10 @@ const StatusKey = "db_backup_last"
// failed every try: a restore from it brings back no servers.
var ErrServersMissing = errors.New("the bundle holds the database but not the MinecraftServer objects")
// StaleAfter is how old the newest backup may get before it counts as missed:
// a day plus the timer's randomized delay and a slow dump. `felis db check`,
// the admin panel and the FelisDBBackupStale alert (deploy/alerts) share it.
// StaleAfter is how old the newest daily backup may get before it counts as
// missed: a day plus the timer's randomized delay and a slow dump. `felis db
// check`, the watchdog, the admin panel and the FelisDBBackupStale alert
// (deploy/alerts) share it.
const StaleAfter = 26 * time.Hour
// Status is the value stored under StatusKey.
@@ -174,6 +175,11 @@ type Status struct {
// ServersError is why the bundle lacks the MinecraftServer objects, when
// it does.
ServersError string `json:"servers_error,omitempty"`
// DailyAt is when the newest daily bundle in Dir was written, as of this
// record: the timer's own freshness, which a manual, pre-migrate or
// off-site bundle taken since leaves as it was. Zero when Dir held none,
// and in records written before the field existed.
DailyAt time.Time `json:"daily_at,omitzero"`
}
// Manifest describes a bundle.
@@ -402,6 +408,17 @@ func List(dir string) ([]Bundle, error) {
return out, nil
}
// Newest is the first bundle of label in bundles, which List orders newest
// first.
func Newest(bundles []Bundle, label string) (Bundle, bool) {
for _, b := range bundles {
if b.Label == label {
return b, true
}
}
return Bundle{}, false
}
// Prune deletes all but the newest keep bundles of label (and their sidecars)
// and returns what it removed. keep <= 0 removes nothing.
func Prune(dir, label string, keep int) ([]string, error) {
@@ -626,9 +643,15 @@ func listArchive(ctx context.Context, c conn, t Tools, path string) error {
return err
}
// record upserts st into platform_settings. The JSON travels as a psql
// variable, quoted by psql itself, over stdin (-c does not interpolate).
// record upserts st into platform_settings, with DailyAt read off st.Dir. The
// JSON travels as a psql variable, quoted by psql itself, over stdin (-c does
// not interpolate).
func record(ctx context.Context, c conn, t Tools, st Status) error {
if all, err := List(st.Dir); err == nil {
if d, ok := Newest(all, LabelDaily); ok {
st.DailyAt = d.Created
}
}
v, err := json.Marshal(st)
if err != nil {
return err
@@ -835,19 +858,21 @@ func Age(d time.Duration) string {
}
}
// Check reports the newest bundle in dir and an error when there is none or it
// is older than maxAge.
// Check reports the newest daily bundle in dir and an error when there is none
// or it is older than maxAge. The daily timer is what keeps the backups
// current, so a manual, pre-migrate or off-site bundle taken since is left out:
// it would hide a timer that has stopped.
func Check(dir string, maxAge time.Duration, now time.Time) (*Bundle, error) {
all, err := List(dir)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, fmt.Errorf("no database backup in %s", dir)
daily, ok := Newest(all, LabelDaily)
if !ok {
return nil, fmt.Errorf("no daily database backup in %s (felis-db-backup.timer writes them)", dir)
}
newest := all[0]
if age := now.Sub(newest.Created); age > maxAge {
return &newest, fmt.Errorf("newest database backup %s is %s old (limit %s)", newest.Name, Age(age), maxAge)
if age := now.Sub(daily.Created); age > maxAge {
return &daily, fmt.Errorf("newest daily database backup %s is %s old (limit %s)", daily.Name, Age(age), maxAge)
}
return &newest, nil
return &daily, nil
}
+29 -4
View File
@@ -290,9 +290,19 @@ func TestBackupRecordsFreshness(t *testing.T) {
}
st := pg.recorded(t)
info, _ := os.Stat(path)
if st.Name != filepath.Base(path) || st.Label != LabelDaily || !st.At.Equal(t0) || st.SizeBytes != info.Size() || st.SchemaVersion != 21 {
if st.Name != filepath.Base(path) || st.Label != LabelDaily || !st.At.Equal(t0) || st.SizeBytes != info.Size() || st.SchemaVersion != 21 || !st.DailyAt.Equal(t0) {
t.Fatalf("recorded %+v", st)
}
// A manual bundle an hour later is the newest record, and carries the
// daily one's time for the panel to judge the timer by.
if _, err := Backup(context.Background(), BackupOptions{
DatabaseURL: testURL, Dir: dir, Label: LabelManual, Tools: pg.tools, Now: at(t0.Add(time.Hour)), Record: true,
}); err != nil {
t.Fatal(err)
}
if st := pg.recorded(t); st.Label != LabelManual || !st.At.Equal(t0.Add(time.Hour)) || !st.DailyAt.Equal(t0) {
t.Fatalf("recorded after a manual backup %+v, want daily_at %s", st, t0)
}
prom, err := os.ReadFile(metrics)
if err != nil {
@@ -701,15 +711,30 @@ func TestListPruneCheck(t *testing.T) {
t.Error("a pruned bundle's sidecar survived")
}
if b, err := Check(dir, 26*time.Hour, t0); err != nil || b.Label != LabelPreMigrate {
// Check goes by the newest daily bundle: the pre-migrate one taken an hour
// ago says nothing about the timer.
if b, err := Check(dir, 26*time.Hour, t0); err != nil || b.Name != "felis-db-20260923T033000Z-daily.tar" {
t.Errorf("Check fresh = %v, %v", b, err)
}
if _, err := Check(dir, 26*time.Hour, t0.Add(30*time.Hour)); err == nil {
t.Error("Check accepted a 31h-old newest bundle")
if b, err := Check(dir, 26*time.Hour, t0.Add(3*time.Hour)); err == nil || b.Name != "felis-db-20260923T033000Z-daily.tar" ||
err.Error() != "newest daily database backup felis-db-20260923T033000Z-daily.tar is 27h0m old (limit 26h0m0s)" {
t.Errorf("Check with a fresh pre-migrate bundle over a 27h-old daily one = %v, %v", b, err)
}
if _, err := Check(filepath.Join(dir, "none"), time.Hour, t0); err == nil {
t.Error("Check accepted an empty directory")
}
only := t.TempDir()
if _, err := Backup(context.Background(), BackupOptions{DatabaseURL: testURL, Dir: only, Label: LabelManual, Tools: pg.tools, Now: at(t0), Record: true}); err != nil {
t.Fatal(err)
}
// No daily bundle: the record leaves daily_at out (a zero time would read
// as year 1), so the panel shows none.
if args, _ := os.ReadFile(filepath.Join(pg.dir, "record.args")); !strings.Contains(string(args), `"label":"manual"`) || strings.Contains(string(args), "daily_at") {
t.Errorf("record without a daily bundle = %s", args)
}
if _, err := Check(only, 26*time.Hour, t0); err == nil || err.Error() != "no daily database backup in "+only+" (felis-db-backup.timer writes them)" {
t.Errorf("Check with a fresh manual bundle alone = %v", err)
}
}
func TestParseBundleName(t *testing.T) {
+21 -8
View File
@@ -341,8 +341,12 @@ func PostgresDown(err error) Finding {
}
}
// BackupFinding reports a control-plane database backup older than a day, or
// none at all, in dir, and a fresh one that would restore no servers.
// BackupFinding reports a daily control-plane database backup older than a
// day, or none at all, in dir, and a fresh one that would restore no servers.
// The age goes by the daily bundles alone: a manual or off-site bundle taken
// since would keep a stopped timer quiet until it too is a day old. The
// servers check reads the newest bundle of any label, the one a lost host
// restores from.
func BackupFinding(dir string, now time.Time) *Finding {
bundles, err := dbbackup.List(dir)
if err != nil {
@@ -353,19 +357,28 @@ func BackupFinding(dir string, now time.Time) *Finding {
Hint: "docs/troubleshooting.md §16",
}
}
if len(bundles) > 0 && now.Sub(bundles[0].Created) <= maxBackupAge {
daily, ok := dbbackup.Newest(bundles, dbbackup.LabelDaily)
if ok && now.Sub(daily.Created) <= maxBackupAge {
return serversFinding(bundles[0])
}
f := &Finding{
Key: "db-backup", Severity: Critical, For: backupFor,
Summary: fmt.Sprintf("%s 里没有任何控制面数据库备份", dir),
SummaryEN: fmt.Sprintf("no control-plane database backup in %s", dir),
Hint: "journalctl -u felis-db-backup -n 50; take one now with `sudo felis db backup` (docs/troubleshooting.md §16)",
Hint: "journalctl -u felis-db-backup -n 50; once fixed, run it now with `sudo systemctl start felis-db-backup` (docs/troubleshooting.md §16)",
}
if len(bundles) > 0 {
age := roundHours(now.Sub(bundles[0].Created))
f.Summary = fmt.Sprintf("最新的控制面数据库备份已是 %s 前(%s)", age, bundles[0].Name)
f.SummaryEN = fmt.Sprintf("the newest control-plane database backup is %s old (%s)", age, bundles[0].Name)
switch {
case ok:
age := roundHours(now.Sub(daily.Created))
f.Summary = fmt.Sprintf("最新的每日控制面数据库备份已是 %s 前(%s)", age, daily.Name)
f.SummaryEN = fmt.Sprintf("the newest daily control-plane database backup is %s old (%s)", age, daily.Name)
case len(bundles) > 0:
f.Summary = fmt.Sprintf("%s 里没有每日定时的控制面数据库备份", dir)
f.SummaryEN = fmt.Sprintf("no daily control-plane database backup in %s", dir)
}
if len(bundles) > 0 && bundles[0].Label != dbbackup.LabelDaily {
f.Summary += fmt.Sprintf(";更新的 %s 来自手动或其他任务,定时任务修好之前它会一天天变旧", bundles[0].Name)
f.SummaryEN += fmt.Sprintf("; the newer %s came from a manual run or another job and ages while the timer stays broken", bundles[0].Name)
}
return f
}
+24 -6
View File
@@ -149,16 +149,34 @@ func TestBackupFinding(t *testing.T) {
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "no control-plane database backup") {
t.Fatalf("empty dir: %+v", f)
}
touch := func(at time.Time) {
if err := os.WriteFile(filepath.Join(dir, dbbackup.BundleName(at, "daily")), []byte("x"), 0o600); err != nil {
touch := func(at time.Time, label string) {
if err := os.WriteFile(filepath.Join(dir, dbbackup.BundleName(at, label)), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
touch(t0.Add(-30 * time.Hour))
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "30h old") {
t.Fatalf("stale: %+v", f)
// A manual bundle alone: the timer has never written one.
touch(t0.Add(-time.Hour), "manual")
if f := BackupFinding(dir, t0); f == nil || f.Key != "db-backup" ||
f.SummaryEN != "no daily control-plane database backup in "+dir+"; the newer felis-db-20260924T110000Z-manual.tar came from a manual run or another job and ages while the timer stays broken" ||
f.Summary != dir+" 里没有每日定时的控制面数据库备份;更新的 felis-db-20260924T110000Z-manual.tar 来自手动或其他任务,定时任务修好之前它会一天天变旧" {
t.Fatalf("manual only: %+v", f)
}
touch(t0.Add(-2 * time.Hour))
// A stale daily bundle under that fresh manual one: still the timer's alarm.
touch(t0.Add(-30*time.Hour), "daily")
if f := BackupFinding(dir, t0); f == nil || f.Key != "db-backup" ||
f.SummaryEN != "the newest daily control-plane database backup is 30h old (felis-db-20260923T060000Z-daily.tar); the newer felis-db-20260924T110000Z-manual.tar came from a manual run or another job and ages while the timer stays broken" ||
!strings.Contains(f.Hint, "sudo systemctl start felis-db-backup") {
t.Fatalf("stale daily under a fresh manual: %+v", f)
}
stale := t.TempDir()
if err := os.WriteFile(filepath.Join(stale, dbbackup.BundleName(t0.Add(-30*time.Hour), "daily")), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if f := BackupFinding(stale, t0); f == nil || f.SummaryEN != "the newest daily control-plane database backup is 30h old (felis-db-20260923T060000Z-daily.tar)" ||
f.Summary != "最新的每日控制面数据库备份已是 30h 前(felis-db-20260923T060000Z-daily.tar)" {
t.Fatalf("stale daily alone: %+v", f)
}
touch(t0.Add(-2*time.Hour), "daily")
if f := BackupFinding(dir, t0); f != nil {
t.Fatalf("fresh backup reported: %+v", f)
}