package account import ( "context" "time" "github.com/google/uuid" "go.uber.org/zap" ) // suspensionSweepInterval is how often the sweeper re-checks for temporary blocks // that lapsed. A minute is well under the coarsest block grain (operators pick day // presets) while keeping the query trivial. const suspensionSweepInterval = time.Minute // suspensionExpiryQuerier is the slice of the account store the sweeper depends on: // the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake // drives the sweeper's unit tests. type suspensionExpiryQuerier interface { SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) } // SuspensionSweeper re-evaluates chat write access when a temporary block self- // expires. No operator action fires on expiry — the suspension gate just re-reads // the wall clock — so without this a temporarily blocked player would stay muted in // the moderated discussion chat after their block lapsed. Each tick it finds blocks // that expired since the previous tick and calls onExpire for the affected accounts; // onExpire is wired to publish the chat-access-changed event, after which the gateway // re-resolves the true eligibility. A liberal call (an account that still has another // active block) is therefore harmless. The window is in-memory, so a block that // expires while the process is down is not re-granted until the next operator action // or the player rejoins — an accepted best-effort gap. type SuspensionSweeper struct { store suspensionExpiryQuerier onExpire func(accountID uuid.UUID) log *zap.Logger // since is the upper bound of the previous swept window; the next sweep covers // (since, now]. It advances only on a successful query, so a failed tick retries // the same window rather than dropping expiries. since time.Time } // NewSuspensionSweeper builds the sweeper over the account store, the per-account // expiry callback (publishing the chat-access-changed event) and a logger. The first // window opens at construction time, so blocks that lapsed earlier are not re-emitted. func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper { if log == nil { log = zap.NewNop() } return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()} } // Interval reports the sweep cadence, for the startup log line. func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval } // Run sweeps every Interval until ctx is cancelled. func (w *SuspensionSweeper) Run(ctx context.Context) { ticker := time.NewTicker(suspensionSweepInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: w.sweep(ctx) } } } // sweep emits a chat-access-changed signal for every account whose temporary block // lapsed in (since, now], then advances the window. On a query error it keeps the // window so the next tick retries it. func (w *SuspensionSweeper) sweep(ctx context.Context) { now := time.Now().UTC() ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now) if err != nil { w.log.Warn("suspension expiry sweep failed", zap.Error(err)) return } w.since = now for _, id := range ids { w.onExpire(id) } }