diff --git a/modules/restic/cmd/restic-backups/backups.go b/modules/restic/cmd/restic-backups/backups.go index 59fd91f52..1ac10e8bf 100644 --- a/modules/restic/cmd/restic-backups/backups.go +++ b/modules/restic/cmd/restic-backups/backups.go @@ -626,7 +626,7 @@ func (b *Backups) BackedUp(ctx context.Context, module string) ([]ModuleBackups, } listed[d.Module] = true m := ModuleBackups{Module: d.Module, Runs: len(d.Runs), Paths: d.Paths, Retention: inForce, Thinned: thinned, - Data: itemReports(items[d.Module], measured[d.Module], d.Paths, good[d.Module]), + Data: b.withPutOff(d.Module, itemReports(items[d.Module], measured[d.Module], d.Paths, good[d.Module])), HandTaken: handKept(d.Module, snaps, goodNights, inForce)} if n, ok := nights[d.Module]; ok { m.LastNight = &n @@ -645,7 +645,7 @@ func (b *Backups) BackedUp(ctx context.Context, module string) ([]ModuleBackups, if listed[name] || (module != "" && name != module) { continue } - out = append(out, ModuleBackups{Module: name, Paths: []string{}, HandTaken: []HandKept{}, Data: itemReports(its, measured[name], nil, time.Time{})}) + out = append(out, ModuleBackups{Module: name, Paths: []string{}, HandTaken: []HandKept{}, Data: b.withPutOff(name, itemReports(its, measured[name], nil, time.Time{}))}) } return out, nil } diff --git a/modules/restic/cmd/restic-backups/data.go b/modules/restic/cmd/restic-backups/data.go index 950146ebb..56e4ce7c9 100644 --- a/modules/restic/cmd/restic-backups/data.go +++ b/modules/restic/cmd/restic-backups/data.go @@ -37,6 +37,8 @@ type ItemReport struct { Peak *int64 `json:"peak_bytes,omitempty"` PeakDay string `json:"peak_day,omitempty"` LastBackup *time.Time `json:"last_backup,omitempty"` + // PutOff is set while the item waits for a restore of the recovery module: its act and since when (§563). + PutOff *PutOff `json:"put_off,omitempty"` } var safePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`) diff --git a/modules/restic/cmd/restic-backups/items.go b/modules/restic/cmd/restic-backups/items.go index e7ba18e48..23c0e059b 100644 --- a/modules/restic/cmd/restic-backups/items.go +++ b/modules/restic/cmd/restic-backups/items.go @@ -166,9 +166,12 @@ func (b *Backups) beginItems(ctx context.Context, module string) (func(err error if err := end(ItemPutOff, map[string]any{"restore": act}); err != nil { return nothing, fmt.Errorf("the put-off of %s's items could not be said: %w", module, err) } + b.keepPutOff(items, act) if err := b.Bus.WaitRestored(ctx, act); err != nil { + b.clearPutOff(module) return nothing, fmt.Errorf("the end of the restore %s could not be waited for: %w", act, err) } + b.clearPutOff(module) if err := started(); err != nil { return nothing, fmt.Errorf("the start of %s's items could not be said: %w", module, err) } diff --git a/modules/restic/cmd/restic-backups/items_test.go b/modules/restic/cmd/restic-backups/items_test.go index 88d022f2d..f5eb5d14a 100644 --- a/modules/restic/cmd/restic-backups/items_test.go +++ b/modules/restic/cmd/restic-backups/items_test.go @@ -15,6 +15,8 @@ type itemBusFake struct { restoring []string // what each read of the recovery module's restores answers, in turn unended []ItemRef runFailure error + // waiting runs while the holder waits for a restore's restored. + waiting func() } func (f *itemBusFake) Say(event string, ref ItemRef, body map[string]any) error { @@ -46,6 +48,9 @@ func (f *itemBusFake) Restoring(module string) (string, error) { } func (f *itemBusFake) WaitRestored(_ context.Context, act string) error { f.log = append(f.log, "restored "+act) + if f.waiting != nil { + f.waiting() + } return nil } @@ -102,6 +107,20 @@ func TestEveryItemOfABackedUpModuleSaysItsStartAndItsEnd(t *testing.T) { func TestTheBackupOfTheBusGivesWayToARestoreThatRuns(t *testing.T) { bus := &itemBusFake{restoring: []string{"act-7"}} b := itemsBackups(t, bus, "postgres", nil) + // While it waits, `backed-up` lists the item put off with the restore's act and since when (BKP8, §302, §664). + bus.waiting = func() { + got, err := b.BackedUp(context.Background(), "postgres") + must(t, err) + var store *ItemReport + for i := range got[0].Data { + if got[0].Data[i].ID == "store" { + store = &got[0].Data[i] + } + } + if store == nil || store.PutOff == nil || store.PutOff.Restore != "act-7" || !store.PutOff.Since.Equal(b.Now()) { + t.Errorf("backed-up says the item as %+v", store) + } + } if n := b.one(context.Background(), declaredOf(t, b, "postgres"), false); !n.OK { t.Fatalf("%+v", n) } @@ -114,6 +133,13 @@ func TestTheBackupOfTheBusGivesWayToARestoreThatRuns(t *testing.T) { if !reflect.DeepEqual(bus.log, want) { t.Fatalf("said %q", bus.log) } + got, err := b.BackedUp(context.Background(), "postgres") + must(t, err) + for _, it := range got[0].Data { + if it.PutOff != nil { + t.Errorf("%s is still put off once it ran: %+v", it.ID, it.PutOff) + } + } } // Design 47 G §563 (BKP4): at its start the holder ends every item it said started and never ended, failed with the error stopped. diff --git a/modules/restic/cmd/restic-backups/putoff.go b/modules/restic/cmd/restic-backups/putoff.go new file mode 100644 index 000000000..13b3d1b67 --- /dev/null +++ b/modules/restic/cmd/restic-backups/putoff.go @@ -0,0 +1,84 @@ +package main + +// An item put off for a restore (design 47 G §563, §302, §664): `backed-up` lists it with the restore's act and since when. The +// night's process keeps it in the module's state directory while the item waits, and the tools' runtime reads it there. + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// PutOff says an item gave way to a restore: the act of that restore and since when. +type PutOff struct { + Restore string `json:"restore"` + Since time.Time `json:"since"` +} + +type putOffItem struct { + Module string `json:"module"` + Item string `json:"item"` + PutOff +} + +func (b *Backups) putOffFile() string { return filepath.Join(b.Where.State, "put-off.json") } + +func (b *Backups) readPutOff() []putOffItem { + raw, err := os.ReadFile(b.putOffFile()) + if err != nil { + return nil + } + var all []putOffItem + _ = json.Unmarshal(raw, &all) + return all +} + +func (b *Backups) writePutOff(all []putOffItem) { + if len(all) == 0 { + if err := os.Remove(b.putOffFile()); err != nil && !os.IsNotExist(err) { + b.Say("the items put off could not be cleared: %v", err) + } + return + } + raw, _ := json.MarshalIndent(all, "", " ") + if err := os.WriteFile(b.putOffFile(), append(raw, '\n'), 0o600); err != nil { + b.Say("the items put off could not be recorded: %v", err) + } +} + +// keepPutOff records a module's items as put off for a restore, since now. +func (b *Backups) keepPutOff(items []ItemRef, act string) { + all := b.readPutOff() + for _, ref := range items { + all = append(all, putOffItem{Module: ref.Module, Item: ref.Item, PutOff: PutOff{Restore: act, Since: b.Now().UTC()}}) + } + b.writePutOff(all) +} + +// clearPutOff forgets a module's items put off: they started again. +func (b *Backups) clearPutOff(module string) { + var keep []putOffItem + for _, p := range b.readPutOff() { + if p.Module != module { + keep = append(keep, p) + } + } + b.writePutOff(keep) +} + +// withPutOff says on a module's item reports which items are put off. +func (b *Backups) withPutOff(module string, reports []ItemReport) []ItemReport { + for _, p := range b.readPutOff() { + if p.Module != module { + continue + } + for i := range reports { + if reports[i].ID == p.Item { + put := p.PutOff + reports[i].PutOff = &put + } + } + } + return reports +}