feat: repair Redis candidates from durable source

This commit is contained in:
Josh Creek
2026-09-01 09:02:55 +01:00
parent eadaa7b54e
commit c8a542a3af
4 changed files with 106 additions and 11 deletions
+48
View File
@@ -0,0 +1,48 @@
package store
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/redis/go-redis/v9"
)
func TestCandidateProjectionRepairsPartialRedisStateFromDurableSource(t *testing.T) {
mini, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer mini.Close()
client := redis.NewClient(&redis.Options{Addr: mini.Addr()})
defer client.Close()
now := time.Unix(1000, 0).UTC()
candidate := domain.Candidate{TicketID: "repair-ticket", PlayerID: "repair-player", EnqueuedAt: now}
index := RedisCandidateIndex{Client: client, Prefix: "repair", TTL: time.Minute}
_, orderKey := index.keys()
if err := client.ZAdd(context.Background(), orderKey, redis.Z{Score: float64(now.UnixNano()), Member: candidate.TicketID}).Err(); err != nil {
t.Fatal(err)
}
projection := CandidateProjection{Index: index, Source: func(context.Context, time.Time) ([]domain.Candidate, error) {
return []domain.Candidate{candidate}, nil
}}
got, err := projection.Snapshot(context.Background(), now)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].TicketID != candidate.TicketID {
t.Fatalf("repaired projection = %+v", got)
}
}
func TestCandidateProjectionDoesNotReturnCacheWhenRepairSourceFails(t *testing.T) {
index := RedisCandidateIndex{TTL: time.Minute}
projection := CandidateProjection{Index: index, Source: func(context.Context, time.Time) ([]domain.Candidate, error) {
return nil, context.DeadlineExceeded
}}
if _, err := projection.Snapshot(context.Background(), time.Unix(1000, 0)); err == nil {
t.Fatal("cache projection succeeded without a usable Redis/index source")
}
}
+54 -8
View File
@@ -19,6 +19,44 @@ type RedisCandidateIndex struct {
TTL time.Duration
}
// DurableCandidateSource is the authoritative queue projection used to
// repair Redis. Implementations must apply queue state and expiry rules before
// returning candidates.
type DurableCandidateSource func(context.Context, time.Time) ([]domain.Candidate, error)
// CandidateProjection couples the transient index to its durable repair
// source. A cache miss, partial write, malformed payload, or Redis restart is
// repaired before candidates are returned to a matcher.
type CandidateProjection struct {
Index RedisCandidateIndex
Source DurableCandidateSource
}
func (p CandidateProjection) Repair(ctx context.Context, now time.Time) error {
if p.Source == nil || now.IsZero() {
return fmt.Errorf("invalid candidate repair source")
}
candidates, err := p.Source(ctx, now)
if err != nil {
return err
}
return p.Index.Rebuild(ctx, candidates)
}
func (p CandidateProjection) Snapshot(ctx context.Context, now time.Time) ([]domain.Candidate, error) {
if p.Source == nil {
return nil, fmt.Errorf("invalid candidate repair source")
}
candidates, err := p.Index.Snapshot(ctx, now)
if err == nil {
return candidates, nil
}
if err := p.Repair(ctx, now); err != nil {
return nil, err
}
return p.Index.Snapshot(ctx, now)
}
func (r RedisCandidateIndex) keys() (string, string) {
prefix := r.Prefix
if prefix == "" {
@@ -105,17 +143,25 @@ func (r RedisCandidateIndex) Snapshot(ctx context.Context, now time.Time) ([]dom
return nil, err
}
result := make([]domain.Candidate, 0, len(payloads))
for _, raw := range payloads {
text, ok := raw.(string)
if !ok {
continue
for i, raw := range payloads {
var encoded []byte
switch value := raw.(type) {
case string:
encoded = []byte(value)
case []byte:
encoded = value
default:
return nil, fmt.Errorf("candidate payload missing for %s", tickets[i])
}
var candidate domain.Candidate
if err := json.Unmarshal([]byte(text), &candidate); err != nil {
continue
if err := json.Unmarshal(encoded, &candidate); err != nil {
return nil, fmt.Errorf("invalid candidate payload for %s: %w", tickets[i], err)
}
if err := validateRedisCandidate(candidate); err != nil || candidate.EnqueuedAt.After(now) {
continue
if err := validateRedisCandidate(candidate); err != nil {
return nil, fmt.Errorf("invalid candidate payload for %s: %w", tickets[i], err)
}
if candidate.EnqueuedAt.After(now) {
return nil, fmt.Errorf("candidate payload is newer than its index for %s", tickets[i])
}
result = append(result, candidate)
}