feat(multiplayer): expose server shutdown acknowledgement

This commit is contained in:
Josh Creek
2026-09-01 16:50:26 +01:00
parent 1d926e705b
commit cc12260225
9 changed files with 224 additions and 3 deletions
+77
View File
@@ -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
})
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
func TestServerShutdownSQLUsesIdempotencyLockAndAudit(t *testing.T) {
checks := map[string][]string{
"insert": {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
"select": {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
"match": {"match_id = $1", "server_id = $2", "FOR UPDATE"},
"audit": {"SERVER_SHUTDOWN", "request_id", "metadata"},
}
queries := map[string]string{"insert": ServerShutdownIdempotencyInsertSQL, "select": ServerShutdownIdempotencySelectSQL, "match": ServerShutdownMatchLockSQL, "audit": ServerShutdownAuditSQL}
for name, fragments := range checks {
for _, fragment := range fragments {
if !strings.Contains(queries[name], fragment) {
t.Fatalf("%s query missing %q: %s", name, fragment, queries[name])
}
}
}
}
func TestRecordServerShutdownRejectsInvalidArguments(t *testing.T) {
binding := domain.WorkloadBinding{MatchID: "match-1", ServerID: "server-1"}
now := time.Unix(1000, 0).UTC()
for name, values := range map[string][2]string{
"missing reason": {"", "shutdown-key-123456"},
"short key": {"planned", "short"},
} {
t.Run(name, func(t *testing.T) {
if err := RecordServerShutdown(context.Background(), (*sql.DB)(nil), binding, values[0], values[1], now); err == nil {
t.Fatal("expected validation error")
}
})
}
}