Files
browser/platform/service/game_client_bridge.go
T

727 lines
31 KiB
Go

package service
import (
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
type gameClientBridgeComponentSession struct {
Session domain.ClientManagerSession
Installation domain.ClientManagerInstallation
}
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if !canAccessServer(user, instance) {
return domain.GameClientBridgeCommand{}, ErrForbidden
}
if instance.PluginID != request.PluginID {
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
}
return svc.queueGameClientBridgeCommand(user.ID, request)
}
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
return domain.GameClientBridgeStatus{}, err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
continue
}
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
for _, installation := range installations {
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
continue
}
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
if listErr != nil {
return domain.GameClientBridgeStatus{}, listErr
}
for _, session := range sessions {
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
declaration.Available = true
declaration.Reason = ""
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
break
}
}
}
status.Profiles = append(status.Profiles, declaration)
status.Available = status.Available || declaration.Available
}
if len(status.Profiles) > 0 {
status.Reason = "no compatible companion session is online"
}
if status.Available {
status.Reason = ""
}
status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles)
return domain.CopyGameClientBridgeStatus(status), nil
}
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
values = append(values, value)
}
}
sort.Strings(values)
return values
}
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
result := make([]domain.GameClientBridgeFeatureAvailability, 0, len(features))
for _, feature := range features {
handlers, producers := map[string]bool{}, map[string]bool{}
for _, profile := range profiles {
if !profile.Available {
continue
}
for _, handler := range profile.HandlerTypes {
handlers[handler] = true
}
for _, producer := range profile.EventProducerTypes {
producers[producer] = true
}
}
missing := make([]string, 0)
for _, handler := range feature.RequiredHandlers {
if !handlers[handler] {
missing = append(missing, "handler."+handler)
}
}
for _, producer := range feature.RequiredEventProducers {
if !producers[producer] {
missing = append(missing, "event-producer."+producer)
}
}
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
if len(missing) > 0 {
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
}
result = append(result, availability)
}
return result
}
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
if err != nil {
return nil, err
}
filter.PluginID = instance.PluginID
commands, err := svc.store.GameClientBridgeCommands().List(filter)
if err != nil {
return nil, err
}
result := make([]domain.GameClientBridgeCommand, len(commands))
for index, command := range commands {
result[index] = domain.CopyGameClientBridgeCommand(command)
}
return result, nil
}
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
return nil, err
}
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
return nil, err
}
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
if err != nil {
return nil, err
}
if query.PluginID != instance.PluginID {
return nil, ErrForbidden
}
limit := query.Limit
if limit == 0 {
limit = 50
}
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
if err != nil {
return nil, err
}
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
for index, snapshot := range snapshots {
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
}
return result, nil
}
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
profileDeclared := false
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
profileDeclared = true
break
}
}
if !profileDeclared {
return domain.GameClientBridgeCommandDeclaration{}, false
}
for _, declaration := range plugin.GameClientBridge.Commands {
if declaration.Type == commandType {
return declaration, true
}
}
return domain.GameClientBridgeCommandDeclaration{}, false
}
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
values[index] = declaration.Type
}
return values
}
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
seen := map[string]struct{}{}
values := make([]string, 0, len(declarations))
for _, declaration := range declarations {
if _, exists := seen[declaration.Type]; exists {
continue
}
seen[declaration.Type] = struct{}{}
values = append(values, declaration.Type)
}
return values
}
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
values[index] = declaration.Key
}
return values
}
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
if declaration == nil {
return nil
}
if len(payload) != 1 {
return validationError("protected bridge request must contain only its declared text field")
}
value, exists := payload[declaration.TextField]
if !exists {
return validationError("protected bridge request text field is required")
}
text, ok := value.(string)
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
return validationError("protected bridge request text is invalid")
}
return nil
}
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
text, _ := payload[declaration.TextField].(string)
digest := sha256.Sum256([]byte(text))
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
}
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
if !declared {
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
}
payload, err := json.Marshal(request.Payload)
if err != nil || len(payload) > declaration.MaxPayloadBytes {
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
}
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
}
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
return domain.GameClientBridgeCommand{}, err
}
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
if err == nil {
return domain.CopyGameClientBridgeCommand(existing), nil
}
if err != nil && err != repo.ErrNotFound {
return domain.GameClientBridgeCommand{}, err
}
if !request.ExpiresAt.After(stamp) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
}
approvalState := domain.GameClientBridgeApprovalNotRequired
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
approvalState = domain.GameClientBridgeApprovalApproved
}
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
approvalState = domain.GameClientBridgeApprovalPending
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
approvalState = domain.GameClientBridgeApprovalApproved
}
}
svc.bridgeSeq++
command := domain.GameClientBridgeCommand{
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
ServerInstanceID: request.ServerInstanceID,
PluginID: request.PluginID,
ProfileKey: request.ProfileKey,
CommandType: request.CommandType,
Payload: domain.CopyGameClientBridgePayload(request.Payload),
IdempotencyKey: request.IdempotencyKey,
Priority: request.Priority,
State: domain.GameClientBridgeCommandPending,
ApprovalState: approvalState,
RequesterID: requesterID,
ExpiresAt: request.ExpiresAt,
CreatedAt: stamp,
UpdatedAt: stamp,
}
summary := "queued declared game client bridge command"
if declaration.ProtectedRequest != nil {
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
}
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = []string{auditID}
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
if limit == 0 {
limit = 10
}
if limit < 1 || limit > 50 {
return nil, validationError("bridge claim limit must be between 1 and 50")
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
return nil, err
}
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
if err != nil {
return nil, err
}
sort.SliceStable(commands, func(left, right int) bool {
if commands[left].Priority != commands[right].Priority {
return commands[left].Priority > commands[right].Priority
}
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
}
return commands[left].ID < commands[right].ID
})
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
for _, command := range commands {
if len(claimed) == limit {
break
}
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
continue
}
fencingToken := command.Claim.FencingToken + 1
command.State = domain.GameClientBridgeCommandClaimed
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
command.UpdatedAt = stamp
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
if auditErr != nil {
return nil, auditErr
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return nil, err
}
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
}
return claimed, nil
}
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.Claim.AcknowledgedAt = stamp
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if isTerminalGameClientBridgeCommandState(command.State) {
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
return domain.CopyGameClientBridgeCommand(command), nil
}
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
}
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
switch request.Status {
case domain.GameClientBridgeResultSucceeded:
command.State = domain.GameClientBridgeCommandSucceeded
case domain.GameClientBridgeResultFailed:
command.State = domain.GameClientBridgeCommandFailed
case domain.GameClientBridgeResultUnknown:
command.State = domain.GameClientBridgeCommandUnknown
case domain.GameClientBridgeResultCancelled:
command.State = domain.GameClientBridgeCommandCancelled
}
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if !canAccessServer(user, instance) {
return domain.GameClientBridgeCommand{}, ErrForbidden
}
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
}
if isTerminalGameClientBridgeCommandState(command.State) {
if command.State == domain.GameClientBridgeCommandCancelled {
return domain.CopyGameClientBridgeCommand(command), nil
}
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
}
command.State = domain.GameClientBridgeCommandCancelled
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
return err
}
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
return err
}
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
}
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if err != nil {
return err
}
groups := map[string][]domain.GameClientBridgeCommand{}
for _, command := range commands {
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
continue
}
retention := defaultGameClientBridgeCommandRetention
maxRecords := 0
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
}
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
}
if !command.CompletedAt.After(stamp.Add(-retention)) {
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
return err
}
continue
}
if maxRecords > 0 {
key := command.ServerInstanceID + "\x00" + command.PluginID
groups[key] = append(groups[key], command)
}
}
for _, group := range groups {
if len(group) == 0 {
continue
}
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
continue
}
sort.SliceStable(group, func(left, right int) bool {
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
return group[left].CompletedAt.After(group[right].CompletedAt)
}
return group[left].ID < group[right].ID
})
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
if err != nil {
return err
}
groups := map[string][]domain.GameClientBridgeSnapshot{}
for _, snapshot := range snapshots {
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
return err
}
continue
}
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
groups[key] = append(groups[key], snapshot)
}
for _, group := range groups {
if len(group) == 0 {
continue
}
maxRecords := group[0].Retention.MaxRecords
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
maxRecords = declaration.Retention.MaxRecords
}
}
if maxRecords <= 0 || len(group) <= maxRecords {
continue
}
sort.SliceStable(group, func(left, right int) bool {
if group[left].Sequence != group[right].Sequence {
return group[left].Sequence > group[right].Sequence
}
return group[left].ID < group[right].ID
})
for _, snapshot := range group[maxRecords:] {
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if err != nil {
return err
}
for _, command := range commands {
if isTerminalGameClientBridgeCommandState(command.State) {
continue
}
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return err
}
continue
}
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
fencingToken := command.Claim.FencingToken
command.State = domain.GameClientBridgeCommandPending
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
command.UpdatedAt = stamp
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
if auditErr != nil {
return auditErr
}
command.AuditReferences = append(command.AuditReferences, auditID)
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
}
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
}
if !command.Claim.LeaseExpiresAt.After(stamp) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
}
return command, nil
}
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
if isTerminalGameClientBridgeCommandState(command.State) {
return nil
}
command.State = domain.GameClientBridgeCommandExpired
command.CompletedAt = stamp
command.UpdatedAt = stamp
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
if err != nil {
return err
}
command.AuditReferences = append(command.AuditReferences, auditID)
return svc.store.GameClientBridgeCommands().Update(command)
}
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
return commandExpiry
}
return leaseExpiry
}
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
}
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
switch state {
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
return true
default:
return false
}
}