mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
320ec46ba2
Both playlists shared one hash and sorted set, causing two independent failures. Starvation: Snapshot performed an unbounded ZRANGEBYSCORE and HMGET, decoded the whole queue, and the matcher then truncated to its candidate limit *before* filtering by playlist. A large casual prefix could therefore leave the ranked worker with zero candidates indefinitely even while ranked tickets were queued further down the set. Mutual erasure: each matcher captured only its own playlist as the durable source, but Rebuild replaced the shared keys, so a casual repair wiped ranked projections and vice versa. Namespace the keys per playlist, push the limit into Redis (LIMIT 0 N) so reads no longer scale with total queue depth, and scope Rebuild to one namespace. Rebuild now rejects a candidate whose playlist does not match the namespace, which would reintroduce the starvation. Upsert derives the namespace from the candidate; Remove takes the playlist, since a ticket ID alone no longer identifies its namespace. Add tests for a 300-deep casual backlog not starving ranked, for neither playlist's rebuild erasing the other, and for the limit being applied without losing enqueue ordering.
126 lines
4.9 KiB
Go
126 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
"github.com/cosmic-clash/cosmic-clash/server/matcher"
|
|
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
|
"github.com/cosmic-clash/cosmic-clash/server/store"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func main() {
|
|
dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string")
|
|
migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations")
|
|
playlist := flag.String("playlist", string(domain.Casual), "playlist to match")
|
|
size := flag.Int("size", 4, "players per match")
|
|
interval := flag.Duration("interval", time.Second, "poll interval")
|
|
redisAddr := flag.String("redis-addr", os.Getenv("COSMIC_CLASH_REDIS_ADDR"), "optional Redis candidate projection address")
|
|
redisPrefix := flag.String("redis-prefix", envOrDefault("COSMIC_CLASH_REDIS_PREFIX", "cosmic-clash"), "Redis key prefix")
|
|
redisTTL := flag.Duration("redis-ttl", 60*time.Second, "TTL for transient candidate projection entries")
|
|
flag.Parse()
|
|
if *dsn == "" {
|
|
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
|
|
}
|
|
if *playlist != string(domain.Casual) && *playlist != string(domain.Ranked) {
|
|
fatalf("unsupported playlist %q", *playlist)
|
|
}
|
|
selectedPlaylist := domain.Playlist(*playlist)
|
|
if selectedPlaylist == domain.Ranked && *size != 6 {
|
|
fatalf("ranked matching requires --size=6")
|
|
}
|
|
if selectedPlaylist == domain.Casual && *size < 2 || selectedPlaylist == domain.Casual && *size > 6 {
|
|
fatalf("casual matching requires --size between 2 and 6")
|
|
}
|
|
if *redisTTL <= 0 {
|
|
fatalf("--redis-ttl must be positive")
|
|
}
|
|
db, err := sql.Open("pgx", *dsn)
|
|
if err != nil {
|
|
fatalf("open PostgreSQL: %v", err)
|
|
}
|
|
defer db.Close()
|
|
startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(startupCtx); err != nil {
|
|
fatalf("ping PostgreSQL: %v", err)
|
|
}
|
|
if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil {
|
|
fatalf("apply migrations: %v", err)
|
|
}
|
|
now := func() time.Time { return time.Now().UTC() }
|
|
var redisClient *redis.Client
|
|
var projection *store.CandidateProjection
|
|
if *redisAddr != "" {
|
|
redisClient = redis.NewClient(&redis.Options{Addr: *redisAddr})
|
|
defer redisClient.Close()
|
|
candidateProjection := store.CandidateProjection{
|
|
Index: store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL},
|
|
Source: func(ctx context.Context, playlist domain.Playlist, at time.Time, limit int) ([]domain.Candidate, error) {
|
|
return store.ListQueuedCandidates(ctx, db, playlist, at, limit)
|
|
},
|
|
}
|
|
projection = &candidateProjection
|
|
}
|
|
worker := matcher.Worker{
|
|
// Both branches are now playlist-filtered and limit-bounded at the
|
|
// source. The Redis branch previously read the whole shared queue,
|
|
// truncated it to limit, and only then filtered by playlist -- so a
|
|
// large casual prefix could leave the ranked worker with zero
|
|
// candidates indefinitely even while ranked tickets were queued.
|
|
Source: func(ctx context.Context, at time.Time, playlist domain.Playlist, limit int) ([]domain.Candidate, error) {
|
|
if projection != nil {
|
|
return projection.Snapshot(ctx, playlist, at, limit)
|
|
}
|
|
return store.ListQueuedCandidates(ctx, db, playlist, at, limit)
|
|
},
|
|
Creator: matcher.ProposalCreatorFunc(func(ctx context.Context, proposal domain.Proposal, ticketIDs map[string]string, at time.Time) error {
|
|
return store.CreateProposal(ctx, db, proposal, ticketIDs, at)
|
|
}),
|
|
Playlist: selectedPlaylist, Size: *size, Now: now,
|
|
NextID: func() string { return fmt.Sprintf("proposal-%d", time.Now().UnixNano()) },
|
|
Prepare: func(id string, playlist domain.Playlist, formation domain.MatchFormation, at time.Time) (domain.PreparedProposal, error) {
|
|
if playlist == domain.Ranked {
|
|
playerIDs := make([]string, 0, len(formation.Selection.Players))
|
|
for _, player := range formation.Selection.Players {
|
|
playerIDs = append(playerIDs, player.PlayerID)
|
|
}
|
|
participants, err := store.LoadRankedParticipants(context.Background(), db, playerIDs)
|
|
if err != nil {
|
|
return domain.PreparedProposal{}, err
|
|
}
|
|
return domain.PrepareProposal(id, playlist, formation, participants, domain.RankedArenaForProposal(id), at)
|
|
}
|
|
return domain.PrepareProposal(id, playlist, formation, nil, domain.RankedArena{}, at)
|
|
},
|
|
OnError: func(err error) { log.Printf("matcher pass: %v", err) },
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := worker.Run(ctx, *interval); err != nil && ctx.Err() == nil {
|
|
fatalf("matcher stopped: %v", err)
|
|
}
|
|
}
|
|
|
|
func envOrDefault(name, fallback string) string {
|
|
if value := os.Getenv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func fatalf(format string, args ...any) {
|
|
log.Printf("matcher: "+format, args...)
|
|
os.Exit(1)
|
|
}
|