mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): expose server shutdown acknowledgement
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const ServerShutdownIdempotencyScope = "server.shutdown"
|
||||
|
||||
const ServerShutdownIdempotencyInsertSQL = `INSERT INTO idempotency_keys
|
||||
(scope, idempotency_key, payload_digest, result)
|
||||
VALUES ($1, $2, $3, '{}'::jsonb) ON CONFLICT (scope, idempotency_key) DO NOTHING`
|
||||
|
||||
const ServerShutdownIdempotencySelectSQL = `SELECT payload_digest
|
||||
FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 FOR UPDATE`
|
||||
|
||||
const ServerShutdownMatchLockSQL = `SELECT match_id
|
||||
FROM matches WHERE match_id = $1 AND server_id = $2 FOR UPDATE`
|
||||
|
||||
const ServerShutdownAuditSQL = `INSERT INTO audit_events
|
||||
(actor_type, actor_id, action, aggregate_type, aggregate_id, request_id, metadata)
|
||||
VALUES ('SERVER', $1, 'SERVER_SHUTDOWN', 'match', $2, $3, $4)`
|
||||
|
||||
// RecordServerShutdown acknowledges a workload-authenticated server's planned
|
||||
// termination without guessing a match-state transition. Result/no-show
|
||||
// transactions own those transitions; this boundary records the server's
|
||||
// lifecycle signal exactly once and is safe to retry.
|
||||
func RecordServerShutdown(ctx context.Context, db *sql.DB, binding domain.WorkloadBinding, reason, idempotencyKey string, now time.Time) error {
|
||||
if db == nil || binding.MatchID == "" || binding.ServerID == "" || reason == "" || len(reason) > 96 || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() {
|
||||
return fmt.Errorf("invalid server shutdown")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s", binding.MatchID, binding.ServerID, reason)))
|
||||
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
||||
inserted, err := tx.ExecContext(ctx, ServerShutdownIdempotencyInsertSQL, ServerShutdownIdempotencyScope, idempotencyKey, digest[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := inserted.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed == 0 {
|
||||
var prior []byte
|
||||
if err := tx.QueryRowContext(ctx, ServerShutdownIdempotencySelectSQL, ServerShutdownIdempotencyScope, idempotencyKey).Scan(&prior); err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(prior, digest[:]) {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var matchID string
|
||||
if err := tx.QueryRowContext(ctx, ServerShutdownMatchLockSQL, binding.MatchID, binding.ServerID).Scan(&matchID); err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := json.Marshal(map[string]string{"reason": reason})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ServerShutdownAuditSQL, binding.ServerID, matchID, idempotencyKey, metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := json.Marshal(map[string]string{"match_id": matchID, "status": "acknowledged"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, ServerShutdownIdempotencyScope, idempotencyKey, result)
|
||||
return err
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user