mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
82 lines
3.5 KiB
Go
82 lines
3.5 KiB
Go
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::timestamptz,
|
|
'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
|
|
RETURNING event_id
|
|
)
|
|
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
|
|
}
|