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:
Josh Creek
2026-09-05 10:23:52 +01:00
parent f6a87463c5
commit 320ec46ba2
7 changed files with 215 additions and 75 deletions
+8 -16
View File
@@ -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)
},