feat: wire persistent queue backend into API

This commit is contained in:
Josh Creek
2026-08-31 22:17:32 +01:00
parent b2d68d93cf
commit 59cf29949f
4 changed files with 91 additions and 6 deletions
+37 -5
View File
@@ -4,6 +4,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
@@ -22,11 +23,19 @@ type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error)
type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error)
type QueueBackend interface {
Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error)
Heartbeat(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error)
Cancel(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error)
Get(context.Context, string, string, time.Time) (domain.QueueTicket, error)
}
type Service struct {
Sessions *domain.SessionStore
Queue *domain.Queue
Candidate CandidateProvider
CandidateV2 CandidateProviderV2
QueueBackend QueueBackend
Probe ProbeProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
@@ -75,7 +84,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if s.Queue == nil || (s.Candidate == nil && s.CandidateV2 == nil) {
if (s.Queue == nil && s.QueueBackend == nil) || (s.QueueBackend == nil && s.Candidate == nil && s.CandidateV2 == nil) {
writeError(w, http.StatusServiceUnavailable, "queue_unavailable")
return
}
@@ -94,6 +103,15 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
}
now := s.now()
spec := domain.QueueSpec{Playlist: domain.Playlist(input.Playlist), ClientBuild: input.ClientBuild, ProtocolVersion: input.ProtocolVersion}
if s.QueueBackend != nil {
ticket, err := s.QueueBackend.Create(r.Context(), playerID, input.TicketID, key, spec, now)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
return
}
var candidate domain.Candidate
var err error
if s.CandidateV2 != nil {
@@ -131,7 +149,7 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if s.Queue == nil {
if s.Queue == nil && s.QueueBackend == nil {
writeError(w, http.StatusServiceUnavailable, "queue_unavailable")
return
}
@@ -141,7 +159,13 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found")
return
}
ticket, err := s.Queue.Get(playerID, parts[0], s.now())
var ticket domain.QueueTicket
var err error
if s.QueueBackend != nil {
ticket, err = s.QueueBackend.Get(r.Context(), playerID, parts[0], s.now())
} else {
ticket, err = s.Queue.Get(playerID, parts[0], s.now())
}
if err != nil {
writeDomainError(w, err)
return
@@ -166,9 +190,17 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
now := s.now()
var ticket domain.QueueTicket
if parts[1] == "heartbeat" {
ticket, err = s.Queue.Heartbeat(playerID, ticketID, key, revision, now)
if s.QueueBackend != nil {
ticket, err = s.QueueBackend.Heartbeat(r.Context(), playerID, ticketID, key, revision, now)
} else {
ticket, err = s.Queue.Heartbeat(playerID, ticketID, key, revision, now)
}
} else {
ticket, err = s.Queue.Cancel(playerID, ticketID, key, revision, now)
if s.QueueBackend != nil {
ticket, err = s.QueueBackend.Cancel(r.Context(), playerID, ticketID, key, revision, now)
} else {
ticket, err = s.Queue.Cancel(playerID, ticketID, key, revision, now)
}
}
if err != nil {
writeDomainError(w, err)
+38
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,6 +12,22 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
type queueBackendSpy struct{ createCalls int }
func (b *queueBackendSpy) Create(_ context.Context, playerID, ticketID, _ string, spec domain.QueueSpec, now time.Time) (domain.QueueTicket, error) {
b.createCalls++
return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, Playlist: spec.Playlist, State: domain.Queued, EnqueuedAt: now, ExpiresAt: now.Add(domain.QueueExpiryWindow)}, nil
}
func (*queueBackendSpy) Heartbeat(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error) {
return domain.QueueTicket{}, nil
}
func (*queueBackendSpy) Cancel(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error) {
return domain.QueueTicket{}, nil
}
func (*queueBackendSpy) Get(context.Context, string, string, time.Time) (domain.QueueTicket, error) {
return domain.QueueTicket{}, nil
}
func TestAuthenticatedQueueAPIUsesServerCandidateAndRevisionedMutations(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
@@ -184,6 +201,27 @@ func TestQueueCreateRejectsCandidateMetadataMismatch(t *testing.T) {
}
}
func TestQueueAPIUsesInjectedPersistentBackendWithoutCandidateProvider(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, _ := sessions.Issue("player-1", time.Hour, now)
backend := &queueBackendSpy{}
service := &Service{Sessions: sessions, QueueBackend: backend, Now: func() time.Time { return now }}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"ranked","client_build":"build-1","protocol_version":1}`))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "create-key-123456")
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated || backend.createCalls != 1 {
t.Fatalf("status=%d backend_calls=%d", response.StatusCode, backend.createCalls)
}
}
func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()