Merge pull request 'Let restic's thinning wait up to ten minutes for a held repository lock instead of failing at once (issue 620)' (#451) from fix/620-thinning-waits-for-a-lock into main

This commit was merged in pull request #451.
This commit is contained in:
2026-10-22 18:16:37 +00:00
6 changed files with 121 additions and 6 deletions
+6
View File
@@ -18,6 +18,12 @@ Against mistakes, not disasters: nothing leaves the machine.
kept for good. That is a choice: deleting a module's last restore point is a person's act, and
until then it costs only space.
**A repository another restic holds** (a listing of the restore points, say, while a delivery plan backs up
the machine's modules before their moves) is waited for: the thinning asks restic to retry the lock for up to
ten minutes (`--retry-lock`, novox/hq issue 620), and fails only after that. A failed thinning keeps every
restore point, and the module's health stays unhealthy until a thinning succeeds: the next night's, or one
after `node-backup.now`.
`node-backup.backed-up` lists each module's restore points taken by hand under `handTaken`: when
each was taken, the time it is kept at least until, whether it still waits for a later good night,
and a sentence saying so.
+11 -2
View File
@@ -406,6 +406,15 @@ func (b *Backups) backUp(ctx context.Context, only string, hand bool) (map[strin
return outcome, nil
}
// thinningWaitsForALock is how long the thinning waits for a repository another restic holds, with restic's own
// --retry-lock, before it fails as it did before (novox/hq issue 620). The night's lock keeps out this module's own
// backups and restores, not a restic outside it: a listing of the restore points, asked while a delivery plan backs
// up a machine's data modules before their moves (ADR 0233), held the lock 246 ms before a thinning asked for it.
// Ten minutes outlasts such a plan's backups of every data module on a machine (a whole night of fifteen modules
// took under five minutes) and stays well within the half hour after which restic takes a lock as stale; a lock
// held longer than this is not a passing collision, and is said the same night.
const thinningWaitsForALock = 10 * time.Minute
// thin forgets the restore points taken by hand that are past their days and have a later night of their
// module, by id, then thins the rest by the counts, keeping every one still tagged as taken by hand (novox/hq
// issue 453). It answers the short ids it let go — on an error of the counts' thinning too, for those are gone. A list that does not read thins nothing: a restore point
@@ -420,14 +429,14 @@ func (b *Backups) thin(ctx context.Context, keep Retention) ([]string, error) {
ids, letGo = append(ids, s.ID), append(letGo, s.ShortID)
}
if len(ids) > 0 {
if _, err := b.restic(ctx, append([]string{"forget"}, ids...)...); err != nil {
if _, err := b.restic(ctx, append([]string{"forget", "--retry-lock", thinningWaitsForALock.String()}, ids...)...); err != nil {
return nil, err
}
b.Say("let go the restore points taken by hand past %d days with a later night: %s", keep.Hand, strings.Join(letGo, ", "))
}
_, err = b.restic(ctx, "forget", "--prune", "--group-by", "host,tags",
"--keep-daily", fmt.Sprint(keep.Daily), "--keep-weekly", fmt.Sprint(keep.Weekly), "--keep-monthly", fmt.Sprint(keep.Monthly),
"--keep-tag", HandTag)
"--keep-tag", HandTag, "--retry-lock", thinningWaitsForALock.String())
return letGo, err
}
@@ -108,7 +108,7 @@ func TestANightDumpsBeforeEachSnapshotAndOneFailingModuleFailsOnlyItself(t *test
"sh -c fail-it",
"restic backup --json --tag module=mail /",
"restic snapshots --json",
"restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand",
"restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand --retry-lock 10m0s",
}
if !reflect.DeepEqual(calls, want) {
t.Errorf("ran\n%s\nwant\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n"))
@@ -74,7 +74,7 @@ func TestAHandTakenBackupIsTaggedAndTheThinningKeepsThatTag(t *testing.T) {
if got := h.with("restic backup"); len(got) != 2 || got[1] != "restic backup --json --tag module=pg /" {
t.Errorf("the night backed up with %q, want it tagged with its module alone", got)
}
want := "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand"
want := "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand --retry-lock 10m0s"
for _, f := range h.with("restic forget") {
if f != want {
t.Errorf("thinned with %q, want %q", f, want)
@@ -134,7 +134,7 @@ func TestTheThinningForgetsAnExpiredHandTakenRestorePointByItsId(t *testing.T) {
_, err := b.BackUp(context.Background(), "")
must(t, err)
forgets := h.with("restic forget")
if len(forgets) != 2 || forgets[0] != "restic forget aaaaaaaa11" || !strings.HasPrefix(forgets[1], "restic forget --prune") {
if len(forgets) != 2 || forgets[0] != "restic forget --retry-lock 10m0s aaaaaaaa11" || !strings.HasPrefix(forgets[1], "restic forget --prune") {
t.Fatalf("thinned with %q, want the expired one forgotten by id, then the rotation", forgets)
}
th := b.LastRun().Thinned
@@ -0,0 +1,100 @@
package main
// The thinning waits for a repository another restic holds (novox/hq issue 620): met three times, each when a
// restic process outside the night's lock — a listing of the restore points while a delivery plan backs up a
// machine's data modules before their moves (ADR 0233) — held the repository's lock a few hundred milliseconds
// before the thinning asked for it, and the thinning failed at once.
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
)
// lockedRestic is a fake restic whose repository another process holds for heldFor, as restic answers it: a
// forget without --retry-lock fails at once; one with it waits up to its bound, and succeeds when the lock is
// let go within it, else fails with restic's words. waited is how long the forgets it was asked waited.
type lockedRestic struct {
handRestic
heldFor time.Duration
waited []time.Duration
}
const lockSaid = "unable to create lock in backend: repository is already locked by PID 4242 on the-machine by root " +
"(UID 0, GID 0) / lock was created at 2026-10-22 18:14:22 (246 ms ago)"
func (l *lockedRestic) run(ctx context.Context, name string, args ...string) (string, error) {
if name != "restic" || args[5] != "forget" {
return l.handRestic.run(ctx, name, args...)
}
l.calls = append(l.calls, "restic "+strings.Join(args[5:], " "))
bound := time.Duration(0)
if i := slices.Index(args, "--retry-lock"); i >= 0 && i+1 < len(args) {
d, err := time.ParseDuration(args[i+1])
if err != nil {
return "", errors.New("invalid argument for --retry-lock: " + args[i+1])
}
bound = d
}
if l.heldFor > bound {
l.waited = append(l.waited, bound)
return "", errors.New(lockSaid)
}
l.waited = append(l.waited, l.heldFor)
l.heldFor = 0
return "", nil
}
func TestTheThinningWaitsForALockHeldBriefly(t *testing.T) {
for _, held := range []time.Duration{3 * time.Second, 5 * time.Minute} {
l := &lockedRestic{heldFor: held}
b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: time.Now, Say: quiet}
_, err := b.BackUp(context.Background(), "")
must(t, err)
if th := b.LastRun().Thinned; th == nil || !th.OK {
t.Errorf("held for %s: recorded %+v, want the thinning to wait for the lock and succeed", held, th)
}
if why := b.Health(context.Background()).Why; strings.Contains(why, "Thinning the restore points failed") {
t.Errorf("held for %s: the health says %q", held, why)
}
}
}
func TestTheThinningStillFailsOnALockHeldPastItsBound(t *testing.T) {
l := &lockedRestic{heldFor: thinningWaitsForALock + time.Minute}
b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: time.Now, Say: quiet}
_, err := b.BackUp(context.Background(), "")
must(t, err)
th := b.LastRun().Thinned
if th == nil || th.OK || !strings.Contains(th.Error, "already locked") {
t.Fatalf("recorded %+v, want the thinning failed on the lock", th)
}
if !slices.Equal(l.waited, []time.Duration{thinningWaitsForALock}) {
t.Errorf("waited %v, want once, for %s", l.waited, thinningWaitsForALock)
}
why := b.Health(context.Background()).Why
for _, want := range []string{"Thinning the restore points failed at", "already locked", "Nothing was forgotten",
"node-backup.now on this machine thins again"} {
if !strings.Contains(why, want) {
t.Errorf("the health says %q, want %q in it", why, want)
}
}
}
// A restore point taken by hand that is let go by its id waits for the lock as well: it is the thinning too.
func TestTheThinningForgetsByIdWaitingForTheLock(t *testing.T) {
now := time.Date(2026, 10, 20, 3, 0, 0, 0, time.UTC)
l := &lockedRestic{heldFor: 3 * time.Second, handRestic: handRestic{made: "bbbbbbbb11", snaps: []Snapshot{
{ID: "aaaaaaaa11", ShortID: "aaaaaaaa", Time: now.AddDate(0, 0, -9), Tags: []string{"module=pg", HandTag}},
{ID: "bbbbbbbb11", ShortID: "bbbbbbbb", Time: now, Tags: []string{"module=pg"}},
}}}
b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: func() time.Time { return now }, Say: quiet}
_, err := b.BackUp(context.Background(), "")
must(t, err)
if th := b.LastRun().Thinned; th == nil || !th.OK || !slices.Equal(th.LetGo, []string{"aaaaaaaa"}) {
t.Errorf("recorded %+v, want aaaaaaaa let go after waiting for the lock", th)
}
}
@@ -51,7 +51,7 @@ func thinning(t *testing.T, retention *string) (string, []string) {
func TestANightThinsByTheRetentionTheMeshWrote(t *testing.T) {
conf := "# Written by the mesh.\ndaily=7\nweekly=4\nmonthly=24\nhand=10\n"
got, _ := thinning(t, &conf)
if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24 --keep-tag hand"; got != want {
if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24 --keep-tag hand --retry-lock 10m0s"; got != want {
t.Errorf("ran %q, want %q", got, want)
}
}