feat(backups): 主人和管理员可删除单个备份,归档由 reaper 下一轮删除、异地副本随下次同步删除,恢复可能在读时拒绝

This commit is contained in:
Lemon-miaow committed 2026-09-27 18:09:49 +08:00
1 parent db7fbfec46
commit b6751eac0f
23 files changed
+969 -45

No files matched your search

+8 -5
View File
@@ -233,7 +233,7 @@ func (c *fakeCatalog) MarkOffsite(_ context.Context, id string, at time.Time) er
func (c *fakeCatalog) ExpiredRefs(_ context.Context, now time.Time) ([]string, error) {
var out []string
for _, r := range c.rows {
if r.status == "deleted" && r.expires.Before(now) && !r.offsite.IsZero() {
if (r.status == "deleted" || r.status == "expired") && r.expires.Before(now) && !r.offsite.IsZero() {
out = append(out, r.Ref)
}
}
@@ -374,23 +374,26 @@ func TestSyncFailureLeavesRowPending(t *testing.T) {
}
// TestSyncExpiresOnlyPastRetention: a remote archive goes once its row has
// expired; one evicted early from the local disk stays until then, and an
// expired, or once its owner deleted it (status expired, expires_at pulled to
// the delete); one evicted early from the local disk stays until then, and an
// object with no row at all is left alone.
func TestSyncExpiresOnlyPastRetention(t *testing.T) {
cat := &fakeCatalog{rows: []*row{
{WorldBackup: WorldBackup{ID: "old", Ref: "/a/old.tar.gz"}, status: "deleted", expires: now.Add(-time.Hour), offsite: now.Add(-100 * 24 * time.Hour)},
{WorldBackup: WorldBackup{ID: "evicted", Ref: "/a/evicted.tar.gz"}, status: "deleted", expires: now.Add(30 * 24 * time.Hour), offsite: now.Add(-24 * time.Hour)},
{WorldBackup: WorldBackup{ID: "dropped", Ref: "/a/dropped.tar.gz"}, status: "expired", expires: now.Add(-time.Minute), offsite: now.Add(-24 * time.Hour)},
}}
s, b := newSyncer(t, cat)
for _, k := range []string{"worlds/old.tar.gz.fenc", "worlds/evicted.tar.gz.fenc", "worlds/unknown.tar.gz.fenc"} {
for _, k := range []string{"worlds/old.tar.gz.fenc", "worlds/evicted.tar.gz.fenc", "worlds/dropped.tar.gz.fenc", "worlds/unknown.tar.gz.fenc"} {
b.objs[k] = []byte("x")
}
res, err := s.Run(context.Background())
if err != nil {
t.Fatal(err)
}
if res.WorldsExpired != 1 || len(b.removed) != 1 || b.removed[0] != "worlds/old.tar.gz.fenc" {
t.Fatalf("removed %v (expired %d), want only the expired archive", b.removed, res.WorldsExpired)
sort.Strings(b.removed)
if res.WorldsExpired != 2 || len(b.removed) != 2 || b.removed[0] != "worlds/dropped.tar.gz.fenc" || b.removed[1] != "worlds/old.tar.gz.fenc" {
t.Fatalf("removed %v (expired %d), want the expired and the owner-deleted archive", b.removed, res.WorldsExpired)
}
if res.RemoteWorlds != 2 {
t.Fatalf("remote worlds = %d, want 2 left", res.RemoteWorlds)
+1 -1
View File
@@ -44,7 +44,7 @@ func (c PGCatalog) MarkOffsite(ctx context.Context, id string, at time.Time) err
func (c PGCatalog) ExpiredRefs(ctx context.Context, now time.Time) ([]string, error) {
return c.refs(ctx, `SELECT backup_ref FROM world_backups
WHERE status = 'deleted' AND expires_at < $1 AND offsite_at IS NOT NULL`, now)
WHERE status IN ('deleted', 'expired') AND expires_at < $1 AND offsite_at IS NOT NULL`, now)
}
func (c PGCatalog) refs(ctx context.Context, q string, args ...any) ([]string, error) {
+2 -1
View File
@@ -78,7 +78,8 @@ type Catalog interface {
// MarkOffsite records that the archive of row id is in the bucket.
MarkOffsite(ctx context.Context, id string, at time.Time) error
// ExpiredRefs lists the backup_ref of every row past its retention
// (deleted and expires_at < now) whose archive was copied off-site.
// (deleted, or expired: deleted through the API; and expires_at < now)
// whose archive was copied off-site.
ExpiredRefs(ctx context.Context, now time.Time) ([]string, error)
// PresentWorlds lists every present archive, for a restore of the volume.
PresentWorlds(ctx context.Context) ([]WorldBackup, error)