feat: promote accepted proposals from API

This commit is contained in:
Josh Creek
2026-09-01 10:29:41 +01:00
parent e0ba6c6ead
commit 03ff8e485e
16 changed files with 270 additions and 49 deletions
+38 -23
View File
@@ -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))
}
+55
View File
@@ -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)
+12
View File
@@ -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)
})
}
+13
View File
@@ -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")
}
}