fix(reaper): 认领时重置活跃时钟与预警,回收只复用本次认领后的 reaper 归档

This commit is contained in:
Lemon-miaow committed 2026-09-24 19:31:23 +08:00
1 parent 47890ca913
commit 22becd6859
7 files changed
+187 -8

No files matched your search

+8 -3
View File
@@ -50,9 +50,14 @@ func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) {
}
func (s *PGStore) FreshBackup(ctx context.Context, server string, since time.Time) (Fresh, bool, error) {
const q = `SELECT backup_ref, offsite_at IS NOT NULL FROM world_backups
WHERE server_name = $1 AND status = 'present' AND created_at >= $2
ORDER BY offsite_at IS NOT NULL DESC, created_at DESC LIMIT 1`
// Only the reaper's own archives count, and only those taken since the
// current owner claimed the server: a manual backup may predate a panel edit
// that did not move last_active_at, and an archive from before the claim is
// the previous owner's world.
const q = `SELECT b.backup_ref, b.offsite_at IS NOT NULL FROM world_backups b
WHERE b.server_name = $1 AND b.status = 'present' AND b.reason = 'inactive_15d' AND b.created_at >= $2
AND b.created_at >= COALESCE((SELECT s.claimed_at FROM servers s WHERE s.name = $1 AND s.deleted_at IS NULL), '-infinity')
ORDER BY b.offsite_at IS NOT NULL DESC, b.created_at DESC LIMIT 1`
var f Fresh
switch err := s.db.QueryRowContext(ctx, q, server, since).Scan(&f.Ref, &f.Offsite); {
case err == sql.ErrNoRows:
+3 -2
View File
@@ -168,8 +168,9 @@ type Store interface {
// ListActiveServers returns every servers row with deleted_at IS NULL.
ListActiveServers(ctx context.Context) ([]Candidate, error)
// FreshBackup reports an existing present backup for server whose world is
// still current — created at or after since (the world's last_active_at),
// FreshBackup reports an existing present reaper archive (reason
// inactive_15d) for server whose world is still current — created at or
// after since (the world's last_active_at) and after the current claim,
// preferring one already copied off-site. It makes a reap idempotent across
// a DeletePVC failure, and across the wait for the off-site copy: the retry
// reuses the archive instead of writing a duplicate.
+18 -2
View File
@@ -104,6 +104,7 @@ func (c *fakeCluster) Stop(_ context.Context, name string) error {
type fakeBackup struct {
id, server, ref string
reason string
size int64
status string // present | deleted
createdAt, expires time.Time
@@ -137,7 +138,7 @@ func (s *fakeStore) ListActiveServers(context.Context) ([]Candidate, error) {
func (s *fakeStore) FreshBackup(_ context.Context, server string, since time.Time) (Fresh, bool, error) {
var found *fakeBackup
for _, b := range s.backups {
if b.server == server && b.status == "present" && !b.createdAt.Before(since) {
if b.server == server && b.status == "present" && b.reason == ReasonInactive && !b.createdAt.Before(since) {
if found == nil || (b.offsite && !found.offsite) {
found = b
}
@@ -154,7 +155,7 @@ func (s *fakeStore) InsertBackup(_ context.Context, rec BackupRecord) error {
return s.insertErr
}
s.backups = append(s.backups, &fakeBackup{
id: rec.ID, server: rec.ServerName, ref: rec.BackupRef, size: rec.SizeBytes,
id: rec.ID, server: rec.ServerName, ref: rec.BackupRef, reason: rec.Reason, size: rec.SizeBytes,
status: "present", createdAt: s.clock, expires: rec.ExpiresAt,
})
s.rec.add("insert")
@@ -433,6 +434,21 @@ func TestReapDeletePVCFailureIsIdempotent(t *testing.T) {
}
}
// A manual backup taken after the last join is not the reaper's archive: the
// owner may have edited the world from the panel since, which does not move
// last_active_at. The reap writes its own archive.
func TestReapDoesNotReuseManualBackup(t *testing.T) {
r, st, _, ar := newReaper(DefaultConfig(),
Candidate{Name: "eta", OwnerID: "user-7", LastActiveAt: idleBy(20 * Day)})
st.backups = []*fakeBackup{
{id: "man", server: "eta", ref: "ref-man", reason: "manual", size: 5, status: "present", createdAt: idleBy(10 * Day), expires: testNow.Add(80 * Day)},
}
sum := mustRun(t, r)
if sum.WorldsReaped != 1 || ar.archives != 1 {
t.Fatalf("summary = %+v, archives = %d: want the reap to archive afresh", sum, ar.archives)
}
}
// With an off-site bucket configured the reaper archives an idle world but
// keeps it until the archive's copy is confirmed; the next run reuses that
// archive and deletes the world, without archiving it again.