mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
// 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 (
|
|
ResourceQueueTicket ResourceKind = "queue_ticket"
|
|
ResourceProposal ResourceKind = "proposal"
|
|
ResourceMatch 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 ResourceQueueTicket:
|
|
targets = queueTransitions[from]
|
|
case ResourceProposal:
|
|
targets = proposalTransitions[from]
|
|
case ResourceMatch:
|
|
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: {},
|
|
}
|