package store import ( "context" "database/sql" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) // ExpireStalledAllocationsSQL reclaims a match that has sat in // ALLOCATING/PROCESS_READY/ASSIGNMENT_READY past the deadline -- its server // crashed, was reclaimed by Agones as unhealthy, or otherwise never finished // registering. Requeues every participant instead of just failing the match: // task 8.50's own stated acceptance criterion is that "infrastructure-caused // cases cannot penalise affected players", and a server-side failure here is // exactly that, not player behaviour. FOR UPDATE SKIP LOCKED lets a second // maintenance replica continue past whatever a concurrent one is already // reclaiming rather than blocking on it. const ExpireStalledAllocationsSQL = `WITH stalled AS ( SELECT match_id FROM matches WHERE state IN ('ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY') AND created_at <= $1 ORDER BY created_at, match_id LIMIT $2 FOR UPDATE SKIP LOCKED ), failed AS ( UPDATE matches SET state = 'FAILED', revision = revision + 1 WHERE match_id IN (SELECT match_id FROM stalled) RETURNING match_id, revision ), released AS ( UPDATE match_participants SET participation_active = FALSE WHERE match_id IN (SELECT match_id FROM failed) AND participation_active RETURNING ticket_id ), requeued AS ( UPDATE queue_tickets SET state = 'QUEUED', expires_at = $3, revision = revision + 1 WHERE ticket_id IN (SELECT ticket_id FROM released) RETURNING ticket_id ), events AS ( INSERT INTO outbox (event_id, aggregate_type, aggregate_id, revision, event_type, payload) SELECT 'stalled-allocation:' || failed.match_id || ':' || failed.revision, 'match', failed.match_id, failed.revision, 'state_changed', jsonb_build_object( 'event', 'state_changed', 'revision', failed.revision, 'resource_id', failed.match_id, 'occurred_at', $4, 'state', 'FAILED', 'match_id', failed.match_id, 'player_ids', COALESCE(( SELECT jsonb_agg(mp.player_id ORDER BY mp.player_id) FROM match_participants mp WHERE mp.match_id = failed.match_id ), '[]'::jsonb) ) FROM failed ON CONFLICT DO NOTHING ) SELECT (SELECT count(*) FROM failed), (SELECT count(*) FROM requeued), (SELECT count(*) FROM events)` // ExpireStalledAllocations reclaims up to `limit` matches whose // created_at is at or before `now - deadline` and are still stuck in one of // the pre-live allocation states, failing the match and requeuing every // participant's ticket with a fresh expiry rather than penalising them. It // returns the number of matches reclaimed. func ExpireStalledAllocations(ctx context.Context, db *sql.DB, now time.Time, deadline time.Duration, limit int) (int, error) { if db == nil || now.IsZero() || deadline <= 0 || limit < 1 || limit > 1000 { return 0, fmt.Errorf("invalid stalled-allocation maintenance arguments") } var matches, requeued, events int err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { return tx.QueryRowContext(ctx, ExpireStalledAllocationsSQL, now.Add(-deadline), limit, now.Add(domain.QueueExpiryWindow), now).Scan(&matches, &requeued, &events) }) if err != nil { return 0, err } if events != matches { return 0, fmt.Errorf("stalled-allocation outbox count %d does not match reclaimed matches %d", events, matches) } return matches, nil }