mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
fix(server): partition and bound the Redis candidate projection
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.
This commit is contained in:
@@ -66,29 +66,21 @@ func main() {
|
||||
defer redisClient.Close()
|
||||
candidateProjection := store.CandidateProjection{
|
||||
Index: store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL},
|
||||
Source: func(ctx context.Context, at time.Time) ([]domain.Candidate, error) {
|
||||
return store.ListQueuedCandidates(ctx, db, selectedPlaylist, at, 1000)
|
||||
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 {
|
||||
candidates, err := projection.Snapshot(ctx, at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
filtered := make([]domain.Candidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Playlist == playlist {
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
return projection.Snapshot(ctx, playlist, at, limit)
|
||||
}
|
||||
return store.ListQueuedCandidates(ctx, db, playlist, at, limit)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user