feat(backups): 主人和管理员可删除单个备份,归档由 reaper 下一轮删除、异地副本随下次同步删除,恢复可能在读时拒绝
This commit is contained in:
23 files changed
+969
-45
No files matched your search
@@ -4,10 +4,12 @@ package pgint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/offsite"
|
||||
"felis.lolicon.best/internal/reaper"
|
||||
)
|
||||
@@ -58,3 +60,112 @@ func TestOffsiteCatalogNewestCopyAndKeptRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerDeletedBackup: ExpireBackup turns one present backup expired with
|
||||
// expires_at pulled to the delete (never pushed later), after which no read
|
||||
// finds it, the backup budget drops its bytes, the reaper's retention pass
|
||||
// lists it whatever its expires_at, and the off-site sweep drops its copy.
|
||||
func TestOwnerDeletedBackup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sfx := suffix(t)
|
||||
server := "owner-del-" + sfx
|
||||
t.Cleanup(func() { db.ExecContext(ctx, `DELETE FROM world_backups WHERE server_name = $1`, server) })
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
insert := func(id string, size int64, expires time.Time) (string, string) {
|
||||
t.Helper()
|
||||
id += "-" + sfx
|
||||
ref := "/archives/" + id + ".tar.gz"
|
||||
mustExec(t, `INSERT INTO world_backups (id, server_name, backup_ref, size_bytes, reason, status, created_at, expires_at, offsite_at)
|
||||
VALUES ($1, $2, $3, $4, 'manual', 'present', $5, $6, $7)`,
|
||||
id, server, ref, size, now.Add(-reaper.Day), expires, now.Add(-time.Hour))
|
||||
return id, ref
|
||||
}
|
||||
later := now.Add(90 * reaper.Day)
|
||||
del, delRef := insert("del", 700, later)
|
||||
keep, keepRef := insert("keep", 11, later)
|
||||
past, _ := insert("past", 13, now.Add(-time.Minute))
|
||||
row := func(id string) (string, time.Time) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var expires time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT status, expires_at FROM world_backups WHERE id = $1`, id).Scan(&status, &expires); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
return status, expires
|
||||
}
|
||||
|
||||
before, err := repo.BackupStoreBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BackupStoreBytes: %v", err)
|
||||
}
|
||||
if err := repo.ExpireBackup(ctx, del, now); err != nil {
|
||||
t.Fatalf("ExpireBackup: %v", err)
|
||||
}
|
||||
if s, e := row(del); s != "expired" || !e.Equal(now) {
|
||||
t.Fatalf("deleted row = %s expiring %v, want expired expiring %v", s, e, now)
|
||||
}
|
||||
if s, e := row(keep); s != "present" || !e.Equal(later) {
|
||||
t.Fatalf("other row = %s expiring %v, want untouched", s, e)
|
||||
}
|
||||
if after, err := repo.BackupStoreBytes(ctx); err != nil || after != before-700 {
|
||||
t.Fatalf("BackupStoreBytes = %d, %v; want %d", after, err, before-700)
|
||||
}
|
||||
if _, err := repo.BackupByID(ctx, del); !errors.Is(err, api.ErrNotFound) {
|
||||
t.Fatalf("BackupByID(deleted) = %v, want ErrNotFound", err)
|
||||
}
|
||||
for _, id := range []string{del, "missing-" + sfx} {
|
||||
if err := repo.ExpireBackup(ctx, id, now); !errors.Is(err, api.ErrNotFound) {
|
||||
t.Fatalf("ExpireBackup(%s) = %v, want ErrNotFound", id, err)
|
||||
}
|
||||
}
|
||||
if err := repo.ExpireBackup(ctx, past, now); err != nil {
|
||||
t.Fatalf("ExpireBackup(past): %v", err)
|
||||
}
|
||||
if s, e := row(past); s != "expired" || !e.Equal(now.Add(-time.Minute)) {
|
||||
t.Fatalf("past row = %s expiring %v, want expired keeping %v", s, e, now.Add(-time.Minute))
|
||||
}
|
||||
|
||||
// The reaper takes it even at a time before its expires_at.
|
||||
exp, err := reaper.NewPGStore(db).ListExpiredBackups(ctx, now.Add(-reaper.Day))
|
||||
if err != nil {
|
||||
t.Fatalf("ListExpiredBackups: %v", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, b := range exp {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if !slices.Contains(ids, del) || slices.Contains(ids, keep) {
|
||||
t.Fatalf("ListExpiredBackups = %v, want %s and not %s", ids, del, keep)
|
||||
}
|
||||
|
||||
cat := offsite.PGCatalog{DB: db}
|
||||
gone, err := cat.ExpiredRefs(ctx, now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("ExpiredRefs: %v", err)
|
||||
}
|
||||
if !slices.Contains(gone, delRef) || slices.Contains(gone, keepRef) {
|
||||
t.Fatalf("ExpiredRefs = %v, want %s and not %s", gone, delRef, keepRef)
|
||||
}
|
||||
kept, err := cat.KeptRefs(ctx, now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("KeptRefs: %v", err)
|
||||
}
|
||||
if slices.Contains(kept, delRef) || !slices.Contains(kept, keepRef) {
|
||||
t.Fatalf("KeptRefs = %v, want %s and not %s", kept, keepRef, delRef)
|
||||
}
|
||||
|
||||
// The undo troubleshooting.md §10 gives, before the reaper has run: the backup
|
||||
// is restorable again and the next sync copies it off site anew.
|
||||
mustExec(t, `UPDATE world_backups SET status = 'present', expires_at = now() + interval '30 days', offsite_at = NULL
|
||||
WHERE id = '`+del+`' AND status = 'expired'`)
|
||||
if b, err := repo.BackupByID(ctx, del); err != nil || b.BackupRef != delRef {
|
||||
t.Fatalf("BackupByID after the undo = (%+v, %v), want %s", b, err, delRef)
|
||||
}
|
||||
pending, err := cat.PendingWorlds(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingWorlds: %v", err)
|
||||
}
|
||||
if !slices.ContainsFunc(pending, func(w offsite.WorldBackup) bool { return w.ID == del }) {
|
||||
t.Fatalf("PendingWorlds after the undo lacks %s", del)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user