mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: add matchmaking domain state core
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Package domain contains database-independent matchmaking invariants.
|
||||
// Adapters may persist these records in PostgreSQL, but must not redefine
|
||||
// transition, revision, or idempotency behavior.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ResourceKind string
|
||||
|
||||
const (
|
||||
QueueTicket ResourceKind = "queue_ticket"
|
||||
Proposal ResourceKind = "proposal"
|
||||
Match ResourceKind = "match"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Queued State = "QUEUED"
|
||||
Proposed State = "PROPOSED"
|
||||
Accepted State = "ACCEPTED"
|
||||
Allocating State = "ALLOCATING"
|
||||
ProcessReady State = "PROCESS_READY"
|
||||
AssignmentReady State = "ASSIGNMENT_READY"
|
||||
Assigned State = "ASSIGNED"
|
||||
Connecting State = "CONNECTING"
|
||||
Live State = "LIVE"
|
||||
ResultPending State = "RESULT_PENDING"
|
||||
Completed State = "COMPLETED"
|
||||
Cancelled State = "CANCELLED"
|
||||
Expired State = "EXPIRED"
|
||||
Failed State = "FAILED"
|
||||
Open State = "OPEN"
|
||||
Declined State = "DECLINED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("mutation conflict")
|
||||
ErrStaleRevision = errors.New("stale revision")
|
||||
ErrIllegalTransition = errors.New("illegal state transition")
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
Kind ResourceKind
|
||||
ID string
|
||||
State State
|
||||
Revision uint64
|
||||
idempotent map[string]appliedMutation
|
||||
}
|
||||
|
||||
type appliedMutation struct {
|
||||
payloadDigest [32]byte
|
||||
result Result
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Kind ResourceKind
|
||||
ID string
|
||||
State State
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
func NewRecord(kind ResourceKind, id string, state State) *Record {
|
||||
return &Record{Kind: kind, ID: id, State: state, idempotent: make(map[string]appliedMutation)}
|
||||
}
|
||||
|
||||
// Apply performs all validation before changing the record. Replaying an
|
||||
// identical idempotency key returns the original result without advancing the
|
||||
// revision. Reusing a key with a different payload, or presenting a stale
|
||||
// revision, is inert and returns an error.
|
||||
func (r *Record) Apply(idempotencyKey string, payload []byte, expectedRevision uint64, target State) (Result, error) {
|
||||
if idempotencyKey == "" {
|
||||
return Result{}, fmt.Errorf("%w: empty idempotency key", ErrConflict)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
if prior, ok := r.idempotent[idempotencyKey]; ok {
|
||||
if !bytes.Equal(prior.payloadDigest[:], digest[:]) {
|
||||
return Result{}, fmt.Errorf("%w: idempotency key reused with different payload", ErrConflict)
|
||||
}
|
||||
return prior.result, nil
|
||||
}
|
||||
if expectedRevision != r.Revision {
|
||||
return Result{}, fmt.Errorf("%w: expected %d, current %d", ErrStaleRevision, expectedRevision, r.Revision)
|
||||
}
|
||||
if !legalTransition(r.Kind, r.State, target) {
|
||||
return Result{}, fmt.Errorf("%w: %s %s -> %s", ErrIllegalTransition, r.Kind, r.State, target)
|
||||
}
|
||||
|
||||
r.State = target
|
||||
r.Revision++
|
||||
result := Result{Kind: r.Kind, ID: r.ID, State: r.State, Revision: r.Revision}
|
||||
r.idempotent[idempotencyKey] = appliedMutation{payloadDigest: digest, result: result}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func legalTransition(kind ResourceKind, from, to State) bool {
|
||||
var targets []State
|
||||
switch kind {
|
||||
case QueueTicket:
|
||||
targets = queueTransitions[from]
|
||||
case Proposal:
|
||||
targets = proposalTransitions[from]
|
||||
case Match:
|
||||
targets = matchTransitions[from]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var queueTransitions = map[State][]State{
|
||||
Queued: {Proposed, Cancelled, Expired},
|
||||
Proposed: {Queued, Accepted, Cancelled, Expired},
|
||||
Accepted: {Queued, Allocating, Cancelled, Failed},
|
||||
Allocating: {ProcessReady, Failed, Cancelled},
|
||||
ProcessReady: {AssignmentReady, Failed, Cancelled},
|
||||
AssignmentReady: {Assigned, Failed, Cancelled},
|
||||
Assigned: {Connecting, Failed, Cancelled},
|
||||
Connecting: {Live, Failed, Expired},
|
||||
Live: {ResultPending, Failed},
|
||||
ResultPending: {Completed, Failed},
|
||||
Completed: {}, Cancelled: {}, Expired: {}, Failed: {},
|
||||
}
|
||||
|
||||
var proposalTransitions = map[State][]State{
|
||||
Open: {Accepted, Declined, Expired, Cancelled},
|
||||
Accepted: {}, Declined: {}, Expired: {}, Cancelled: {},
|
||||
}
|
||||
|
||||
var matchTransitions = map[State][]State{
|
||||
Allocating: {ProcessReady, Failed, Cancelled},
|
||||
ProcessReady: {AssignmentReady, Failed, Cancelled},
|
||||
AssignmentReady: {Assigned, Failed, Cancelled},
|
||||
Assigned: {Connecting, Failed, Cancelled},
|
||||
Connecting: {Live, Failed, Cancelled},
|
||||
Live: {ResultPending, Failed},
|
||||
ResultPending: {Completed, Failed},
|
||||
Completed: {}, Cancelled: {}, Failed: {},
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyIsAtomicOnIllegalTransitionAndStaleRevision(t *testing.T) {
|
||||
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
|
||||
if _, err := r.Apply("k1", []byte(`{"state":"LIVE"}`), 0, Live); !errors.Is(err, ErrIllegalTransition) {
|
||||
t.Fatalf("illegal transition error = %v", err)
|
||||
}
|
||||
if r.State != Queued || r.Revision != 0 {
|
||||
t.Fatalf("illegal transition mutated record: %+v", r)
|
||||
}
|
||||
if _, err := r.Apply("k2", []byte(`{}`), 99, Proposed); !errors.Is(err, ErrStaleRevision) {
|
||||
t.Fatalf("stale revision error = %v", err)
|
||||
}
|
||||
if r.State != Queued || r.Revision != 0 {
|
||||
t.Fatalf("stale revision mutated record: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReplaysIdenticalIdempotencyWithoutNewRevision(t *testing.T) {
|
||||
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
|
||||
payload := []byte(`{"state":"PROPOSED"}`)
|
||||
first, err := r.Apply("same-key-123456", payload, 0, Proposed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := r.Apply("same-key-123456", payload, 0, Proposed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second || r.Revision != 1 {
|
||||
t.Fatalf("replay advanced or changed result: first=%+v second=%+v record=%+v", first, second, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRejectsIdempotencyKeyPayloadConfusion(t *testing.T) {
|
||||
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
|
||||
if _, err := r.Apply("same-key-123456", []byte("a"), 0, Proposed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Apply("same-key-123456", []byte("b"), 1, Accepted); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("conflicting replay error = %v", err)
|
||||
}
|
||||
if r.State != Proposed || r.Revision != 1 {
|
||||
t.Fatalf("conflicting replay mutated record: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalStatesCannotAdvance(t *testing.T) {
|
||||
for _, state := range []State{Completed, Cancelled, Expired, Failed} {
|
||||
r := NewRecord(QueueTicket, "ticket_1234567890123456", state)
|
||||
if _, err := r.Apply("terminal-key-123", []byte("x"), 0, Live); !errors.Is(err, ErrIllegalTransition) {
|
||||
t.Fatalf("%s transition error = %v", state, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/cosmic-clash/cosmic-clash/server
|
||||
|
||||
go 1.23
|
||||
Reference in New Issue
Block a user