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 }) }