mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
5765532409
The root blocker (issue #14). The worker bound the provider allocation and stopped. Service.PublishRoster and store.SaveVerifiedAssignmentRoster both existed, fully tested, with zero non-test callers, and the production allocator configured neither a roster store nor a signing key. Nothing ever wrote the assignments table. The allocated supervisor fetches a non-empty roster before it launches the game child, so every real allocation failed at that fetch: no match could reach ASSIGNMENT_READY or accept a player. Existing tests seeded assignments directly, which is exactly why the missing hand-off went unnoticed. The worker now builds one join authorisation per durable participant, signs each with the active key, and publishes them. Participants are read through the same query SaveVerifiedAssignmentRoster re-validates against, so the allocator cannot construct a roster the persistence boundary would reject. The manifest commits to a digest over the whole roster, so a server cannot be handed a truncated roster whose surviving entries are each individually valid. Persist the provider endpoint on the allocation: it arrived on the provider response and was never stored, so a worker crashing between allocating and publishing had no endpoint to recover and would have stranded the match permanently. Republishing is idempotent, so that crash now simply retries. cmd/allocator refuses to start without key material rather than running an allocator that binds allocations and silently strands every match. The k8s allocator Deployment mounts the same key set the Fleet does, and both now take the JSON key map so a rotation can publish several. New integration test drives the real worker through to the supervisor's own roster read path without seeding the assignments table. Verified it fails with "assignments = 0, want 2" when the publish step is removed.
174 lines
6.2 KiB
Go
174 lines
6.2 KiB
Go
// Package allocator coordinates provider allocation with durable control-plane
|
|
// state. It does not expose an endpoint until both boundaries succeed.
|
|
package allocator
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/agones"
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
)
|
|
|
|
type Provider interface {
|
|
Allocate(context.Context, domain.AllocationRequest, map[string]string, time.Time) (agones.AllocatedServer, error)
|
|
}
|
|
|
|
type ProviderRecoverer interface {
|
|
RecoverAllocation(context.Context, domain.AllocationRequest, time.Time) (agones.AllocatedServer, bool, error)
|
|
}
|
|
|
|
type Durable interface {
|
|
RecordProviderAllocation(context.Context, domain.Allocation, time.Time) (domain.Allocation, error)
|
|
}
|
|
|
|
type RosterPublisher interface {
|
|
PublishRoster(context.Context, domain.Assignment, []domain.SignedJoinAuthorisation, func([]byte, []byte) bool) error
|
|
}
|
|
|
|
type AllocationBudget interface {
|
|
Allow(region string, now time.Time) error
|
|
}
|
|
|
|
type SharedAllocationQuota interface {
|
|
Consume(context.Context, string, time.Time) error
|
|
}
|
|
|
|
type AllocationMetrics interface {
|
|
ObserveAttempt(string)
|
|
ObserveSuccess(string)
|
|
ObserveFailure(string)
|
|
ObserveDenied(string)
|
|
}
|
|
|
|
type Service struct {
|
|
Provider Provider
|
|
Durable Durable
|
|
Roster RosterPublisher
|
|
Budget AllocationBudget
|
|
Quota SharedAllocationQuota
|
|
Metrics AllocationMetrics
|
|
Now func() time.Time
|
|
}
|
|
|
|
// AllocateAcceptedProposal is the hand-off from proposal consensus to server
|
|
// allocation. Keeping this check beside the provider call prevents a caller
|
|
// from allocating capacity for an OPEN/DECLINED proposal or for a request
|
|
// whose playlist does not match the proposal that produced it.
|
|
func (s Service) AllocateAcceptedProposal(ctx context.Context, proposal domain.Proposal, request domain.AllocationRequest, playlist domain.Playlist, labels map[string]string) (agones.AllocatedServer, error) {
|
|
if proposal.State != domain.Accepted || proposal.Playlist != playlist || len(proposal.Participants) == 0 || (request.Playlist != "" && request.Playlist != playlist) || (proposal.Region != "" && request.Region != proposal.Region) || (proposal.Protocol > 0 && request.Protocol != proposal.Protocol) || request.ArenaPath != proposal.ArenaPath {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
if proposal.Playlist == domain.Ranked && len(proposal.Participants) != 6 {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
if proposal.Playlist == domain.Casual && (len(proposal.Participants) < 2 || len(proposal.Participants) > 6) {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
seen := make(map[string]struct{}, len(proposal.Participants))
|
|
for _, participant := range proposal.Participants {
|
|
if participant.PlayerID == "" || participant.Response != domain.AcceptedResponse {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
if _, exists := seen[participant.PlayerID]; exists {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
seen[participant.PlayerID] = struct{}{}
|
|
}
|
|
if request.MatchID == "" {
|
|
return agones.AllocatedServer{}, domain.ErrAllocationInput
|
|
}
|
|
return s.Allocate(ctx, request, labels)
|
|
}
|
|
|
|
func (s Service) PublishRoster(ctx context.Context, assignment domain.Assignment, roster []domain.SignedJoinAuthorisation, verify func([]byte, []byte) bool) error {
|
|
if s.Roster == nil {
|
|
return errNotConfigured
|
|
}
|
|
if assignment.Allocation.State != domain.ServerAllocated || assignment.Endpoint == "" {
|
|
return domain.ErrManifestRejected
|
|
}
|
|
return s.Roster.PublishRoster(ctx, assignment, roster, verify)
|
|
}
|
|
|
|
func (s Service) Allocate(ctx context.Context, request domain.AllocationRequest, labels map[string]string) (agones.AllocatedServer, error) {
|
|
if s.Provider == nil || s.Durable == nil || s.Now == nil {
|
|
return agones.AllocatedServer{}, errNotConfigured
|
|
}
|
|
now := s.Now()
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveAttempt(request.Region)
|
|
}
|
|
if s.Budget != nil {
|
|
if err := s.Budget.Allow(request.Region, now); err != nil {
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveDenied(request.Region)
|
|
}
|
|
return agones.AllocatedServer{}, err
|
|
}
|
|
}
|
|
if s.Quota != nil {
|
|
if err := s.Quota.Consume(ctx, request.Region, now); err != nil {
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveDenied(request.Region)
|
|
}
|
|
return agones.AllocatedServer{}, err
|
|
}
|
|
}
|
|
result, err := s.Provider.Allocate(ctx, request, labels, now)
|
|
if err != nil {
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveFailure(request.Region)
|
|
}
|
|
return agones.AllocatedServer{}, err
|
|
}
|
|
if err := validateProviderAllocation(request, result); err != nil {
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveFailure(request.Region)
|
|
}
|
|
return agones.AllocatedServer{}, err
|
|
}
|
|
// The client-facing endpoint arrives on the provider result, not on the
|
|
// allocation. Carry it onto the record so publishing the assignment roster
|
|
// -- and recovering after a crash between allocating and publishing -- has
|
|
// an endpoint to work from.
|
|
result.Allocation.Endpoint = result.Endpoint
|
|
recorded, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
|
if err != nil {
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveFailure(request.Region)
|
|
}
|
|
return agones.AllocatedServer{}, err
|
|
}
|
|
result.Allocation = recorded
|
|
if s.Metrics != nil {
|
|
s.Metrics.ObserveSuccess(request.Region)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s Service) RecordProviderAllocation(ctx context.Context, result agones.AllocatedServer, now time.Time) (domain.Allocation, error) {
|
|
if s.Durable == nil || result.Allocation.State != domain.ServerAllocated || result.Endpoint == "" {
|
|
return domain.Allocation{}, domain.ErrAllocationInput
|
|
}
|
|
// Quota is consumed by Allocate before a fresh provider request. This
|
|
// method only reconciles an already-issued provider result after an
|
|
// ambiguous write, so consuming here would charge one allocation twice.
|
|
result.Allocation.Endpoint = result.Endpoint
|
|
allocation, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
|
if s.Metrics != nil {
|
|
if err != nil {
|
|
s.Metrics.ObserveFailure(result.Allocation.Region)
|
|
} else {
|
|
s.Metrics.ObserveSuccess(result.Allocation.Region)
|
|
}
|
|
}
|
|
return allocation, err
|
|
}
|
|
|
|
var errNotConfigured = &configurationError{}
|
|
|
|
type configurationError struct{}
|
|
|
|
func (*configurationError) Error() string { return "allocator service is not configured" }
|