package account import ( "context" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "go.uber.org/zap" "scrabble/backend/internal/postgres/jet/backend/table" ) // ReapAbandonedGuests deletes guest accounts created before olderThan that are // not seated in any game. It returns the number deleted. // // Scope is deliberately "no game seat at all", not merely "no active game": a // finished game belongs to the other players' history, and game_players carries no // ON DELETE CASCADE to accounts (docs/ARCHITECTURE.md §4), so a guest with any seat // is retained (and a delete would be blocked by that foreign key regardless). The // dependent rows of a reaped guest — sessions, identities, account_stats — fall // away through their own ON DELETE CASCADE foreign keys. Account age is the // abandonment signal because sessions are revoke-only with no maintained // last_seen_at, so a lingering session never expires on its own. func (s *Store) ReapAbandonedGuests(ctx context.Context, olderThan time.Time) (int64, error) { stmt := table.Accounts.DELETE().WHERE( table.Accounts.IsGuest.EQ(postgres.Bool(true)). AND(table.Accounts.CreatedAt.LT(postgres.TimestampzT(olderThan))). AND(postgres.NOT(postgres.EXISTS( postgres.SELECT(table.GamePlayers.AccountID). FROM(table.GamePlayers). WHERE(table.GamePlayers.AccountID.EQ(table.Accounts.AccountID)), ))), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return 0, fmt.Errorf("account: reap guests: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("account: reap guests rows affected: %w", err) } return n, nil } // GuestReaper periodically deletes abandoned guest accounts via // Store.ReapAbandonedGuests. It mirrors the game turn-timeout sweeper and the // matchmaker reaper: one background goroutine, started once from main. type GuestReaper struct { store *Store retention time.Duration clock func() time.Time log *zap.Logger } // NewGuestReaper constructs a reaper deleting guests whose account age exceeds // retention. log may be nil. func NewGuestReaper(store *Store, retention time.Duration, log *zap.Logger) *GuestReaper { if log == nil { log = zap.NewNop() } return &GuestReaper{ store: store, retention: retention, clock: func() time.Time { return time.Now().UTC() }, log: log, } } // Run reaps abandoned guests on each tick until ctx is cancelled. func (r *GuestReaper) Run(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: n, err := r.store.ReapAbandonedGuests(ctx, r.clock().Add(-r.retention)) if err != nil { r.log.Warn("guest reap failed", zap.Error(err)) } else if n > 0 { r.log.Info("reaped abandoned guests", zap.Int64("count", n)) } } } }