mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: promote accepted proposals from API
This commit is contained in:
+38
-23
@@ -63,6 +63,14 @@ type ProposalBackend interface {
|
||||
type ProposalMutationBackend interface {
|
||||
Respond(context.Context, string, string, string, bool, uint64, time.Time) (domain.Proposal, error)
|
||||
}
|
||||
type ProposalPromoter interface {
|
||||
Promote(context.Context, domain.Proposal, time.Time) error
|
||||
}
|
||||
type ProposalPromoterFunc func(context.Context, domain.Proposal, time.Time) error
|
||||
|
||||
func (f ProposalPromoterFunc) Promote(ctx context.Context, proposal domain.Proposal, now time.Time) error {
|
||||
return f(ctx, proposal, now)
|
||||
}
|
||||
|
||||
type AssignmentView struct {
|
||||
MatchID string `json:"match_id"`
|
||||
@@ -83,29 +91,30 @@ type AssignmentView struct {
|
||||
type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error)
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
Probe ProbeProvider
|
||||
ProbeRecorder ProbeRecorder
|
||||
WorkloadVerify WorkloadVerifier
|
||||
ResultSubmitter ResultSubmitter
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
ProposalBackend ProposalBackend
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
TierPolicy domain.TierPolicy
|
||||
RateLimiter *RateLimiter
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
Probe ProbeProvider
|
||||
ProbeRecorder ProbeRecorder
|
||||
WorkloadVerify WorkloadVerifier
|
||||
ResultSubmitter ResultSubmitter
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
ProposalBackend ProposalBackend
|
||||
ProposalPromoter ProposalPromoter
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
TierPolicy domain.TierPolicy
|
||||
RateLimiter *RateLimiter
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
}
|
||||
|
||||
func (s *Service) Handler() http.Handler {
|
||||
@@ -598,6 +607,12 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
if updated.State == domain.Accepted && s.ProposalPromoter != nil {
|
||||
if err := s.ProposalPromoter.Promote(r.Context(), updated, now); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "match_promotion_unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.publishProposalEvent(updated, now)
|
||||
writeJSON(w, http.StatusOK, toProposalResponse(updated))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,18 @@ type resultSubmitterSpy struct {
|
||||
result domain.MatchResult
|
||||
}
|
||||
|
||||
type proposalPromoterSpy struct {
|
||||
calls int
|
||||
proposal domain.Proposal
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *proposalPromoterSpy) Promote(_ context.Context, proposal domain.Proposal, _ time.Time) error {
|
||||
p.calls++
|
||||
p.proposal = proposal
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (r *resultSubmitterSpy) SubmitResult(_ context.Context, key string, result domain.MatchResult, _ domain.WorkloadBinding, _ []byte, _ time.Time) error {
|
||||
r.calls++
|
||||
r.key, r.result = key, result
|
||||
@@ -409,6 +421,49 @@ func TestStateChangingAPIActionsPublishTargetedEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalProposalAcceptancePromotesDurableMatchAndFailsRetryably(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
proposal, err := domain.NewProposal("proposal-promote-123456", domain.Casual, []string{"player-1", "player-2"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &proposalBackendSpy{proposal: proposal}
|
||||
promoter := &proposalPromoterSpy{}
|
||||
sessions := domain.NewSessionStore()
|
||||
session1, token1, err := sessions.Issue("player-1", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session2, token2, err := sessions.Issue("player-2", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{Sessions: sessions, ProposalBackend: backend, ProposalPromoter: promoter, Now: func() time.Time { return now }}
|
||||
respond := func(credential, key, revision string) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/proposals/"+proposal.ProposalID+"/accept", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+credential)
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
req.Header.Set("If-Match-Revision", revision)
|
||||
recorder := httptest.NewRecorder()
|
||||
service.proposalMutation(recorder, req)
|
||||
return recorder.Code
|
||||
}
|
||||
credential1 := session1.SessionID + ":" + token1
|
||||
credential2 := session2.SessionID + ":" + token2
|
||||
if status := respond(credential1, "proposal-promote-first", "0"); status != http.StatusOK || promoter.calls != 0 {
|
||||
t.Fatalf("first acceptance status/calls = %d/%d", status, promoter.calls)
|
||||
}
|
||||
if status := respond(credential2, "proposal-promote-final", "1"); status != http.StatusOK || promoter.calls != 1 || promoter.proposal.State != domain.Accepted {
|
||||
t.Fatalf("final acceptance status/promoter = %d/%+v", status, promoter)
|
||||
}
|
||||
promoter.err = errors.New("database unavailable")
|
||||
// A duplicate response is replayed by the durable proposal backend and
|
||||
// retries promotion instead of asking the player to accept again.
|
||||
if status := respond(credential2, "proposal-promote-final", "1"); status != http.StatusServiceUnavailable || promoter.calls != 2 {
|
||||
t.Fatalf("promotion retry status/calls = %d/%d", status, promoter.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Casual, []string{"player-1", "player-2"}, now)
|
||||
|
||||
@@ -46,3 +46,15 @@ func (p postgresProposalBackend) Respond(ctx context.Context, playerID, proposal
|
||||
func ProposalProviderFromStore(db *sql.DB) ProposalBackend {
|
||||
return postgresProposalBackend{db: db}
|
||||
}
|
||||
|
||||
// ProposalPromoterFromStore turns a durably accepted proposal into its exact
|
||||
// matcher-selected ALLOCATING match. The store chooses a deterministic match
|
||||
// ID so an API retry after a transient failure cannot duplicate the match.
|
||||
func ProposalPromoterFromStore(db *sql.DB) ProposalPromoter {
|
||||
return ProposalPromoterFunc(func(ctx context.Context, proposal domain.Proposal, now time.Time) error {
|
||||
if proposal.State != domain.Accepted {
|
||||
return domain.ErrIllegalTransition
|
||||
}
|
||||
return store.PromoteStoredAcceptedProposal(ctx, db, proposal.ProposalID, now)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
func TestAssignmentProviderFromStorePreservesPlayerScopedRecoveryBoundary(t *testing.T) {
|
||||
@@ -25,3 +27,14 @@ func TestProposalProviderFromStoreFailsClosedWithoutDatabase(t *testing.T) {
|
||||
t.Fatal("nil store was treated as an available proposal source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalPromoterFromStoreFailsClosedWithoutDatabase(t *testing.T) {
|
||||
promoter := ProposalPromoterFromStore(nil)
|
||||
if promoter == nil {
|
||||
t.Fatal("proposal promoter was not created")
|
||||
}
|
||||
proposal := domain.Proposal{ProposalID: "proposal-1", State: domain.Accepted}
|
||||
if err := promoter.Promote(context.Background(), proposal, time.Unix(1000, 0)); err == nil {
|
||||
t.Fatal("nil store was treated as an available proposal promoter")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user