mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 04:02:03 +00:00
fix(multiplayer): terminate proposal offenders atomically
This commit is contained in:
@@ -612,12 +612,10 @@ func TestPostgreSQLProposalClaimAndResponseAreAtomic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgreSQLProposalDeclineRequeuesEveryParticipant protects the durable
|
||||
// decline boundary: every ticket returns to QUEUED, while the decliner's
|
||||
// separate penalty prevents an immediate replacement queue ticket. Without
|
||||
// the requeue, tickets are invisible to the matcher and remain trapped in
|
||||
// PROPOSED despite the proposal having closed.
|
||||
func TestPostgreSQLProposalDeclineRequeuesEveryParticipant(t *testing.T) {
|
||||
// TestPostgreSQLProposalDeclineCancelsOffenderAndRequeuesInnocent protects the
|
||||
// durable decline boundary: the offender's ticket becomes terminal while
|
||||
// every innocent ticket keeps its original queue precedence.
|
||||
func TestPostgreSQLProposalDeclineCancelsOffenderAndRequeuesInnocent(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
@@ -659,8 +657,8 @@ func TestPostgreSQLProposalDeclineRequeuesEveryParticipant(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT state, expires_at FROM queue_tickets WHERE ticket_id = 'decline-ticket-1'`).Scan(&stateB, &expiresB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stateA != "QUEUED" {
|
||||
t.Fatalf("decliner's own ticket state = %s, want QUEUED while cooldown is recorded separately", stateA)
|
||||
if stateA != "CANCELLED" {
|
||||
t.Fatalf("decliner's own ticket state = %s, want CANCELLED", stateA)
|
||||
}
|
||||
if stateB != "QUEUED" {
|
||||
t.Fatalf("uninvolved participant's ticket state = %s, want QUEUED -- they must not be stranded by someone else's decline", stateB)
|
||||
@@ -669,8 +667,8 @@ func TestPostgreSQLProposalDeclineRequeuesEveryParticipant(t *testing.T) {
|
||||
t.Fatalf("requeued ticket expiry %v was not refreshed forward from %v", expiresB, now)
|
||||
}
|
||||
|
||||
// The real, end-to-end regression: both players can be proposed a NEW
|
||||
// match instead of ListQueuedCandidates silently never seeing them again.
|
||||
// Only the innocent player can be selected again. A durable cooldown also
|
||||
// rejects a new ticket from the decliner until the policy window ends.
|
||||
candidates, err := ListQueuedCandidates(ctx, db, domain.Casual, now, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list queued candidates: %v", err)
|
||||
@@ -679,17 +677,34 @@ func TestPostgreSQLProposalDeclineRequeuesEveryParticipant(t *testing.T) {
|
||||
for _, candidate := range candidates {
|
||||
found[candidate.PlayerID] = true
|
||||
}
|
||||
if !found["decline-player-a"] || !found["decline-player-b"] {
|
||||
t.Fatalf("requeued players are not visible to the matcher: %+v", candidates)
|
||||
if found["decline-player-a"] || !found["decline-player-b"] {
|
||||
t.Fatalf("matcher did not isolate offender from innocent: %+v", candidates)
|
||||
}
|
||||
var cooldownEnd time.Time
|
||||
if err := db.QueryRow(`SELECT ends_at FROM penalties WHERE player_id = 'decline-player-a' AND kind = 'PROPOSAL_DECLINED'`).Scan(&cooldownEnd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := now.Add(30 * time.Second); !cooldownEnd.Equal(want) {
|
||||
t.Fatalf("decline cooldown end = %v, want %v", cooldownEnd, want)
|
||||
}
|
||||
// Recovering the closed proposal after its old deadline must not convert
|
||||
// the innocent participant's PENDING response into a timeout penalty.
|
||||
if _, err := GetProposal(ctx, db, "decline-player-b", proposal.ProposalID, now.Add(domain.ProposalWindow+time.Second)); err != nil {
|
||||
t.Fatalf("recover declined proposal: %v", err)
|
||||
}
|
||||
var innocentTimeouts int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM penalties WHERE player_id = 'decline-player-b' AND kind = 'PROPOSAL_TIMEOUT'`).Scan(&innocentTimeouts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if innocentTimeouts != 0 {
|
||||
t.Fatalf("innocent participant received %d timeout penalties after decline", innocentTimeouts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgreSQLProposalTimeoutRequeuesEveryParticipant protects the timeout
|
||||
// sibling of the decline path: expiry must requeue every ticket and record a
|
||||
// timeout cooldown for each participant who failed to respond. It uses
|
||||
// GetProposal, the recovery/read path, to exercise a client returning after
|
||||
// it missed the expiry event.
|
||||
func TestPostgreSQLProposalTimeoutRequeuesEveryParticipant(t *testing.T) {
|
||||
// TestPostgreSQLProposalTimeoutExpiresOffenderAndRequeuesAccepted protects the
|
||||
// timeout sibling: accepted participants retain precedence, while no-shows
|
||||
// receive a terminal ticket and cooldown.
|
||||
func TestPostgreSQLProposalTimeoutExpiresOffenderAndRequeuesAccepted(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
@@ -712,9 +727,11 @@ func TestPostgreSQLProposalTimeoutRequeuesEveryParticipant(t *testing.T) {
|
||||
if err := CreateProposal(ctx, db, proposal, map[string]string{"timeout-player-a": "timeout-ticket-0", "timeout-player-b": "timeout-ticket-1"}, now); err != nil {
|
||||
t.Fatalf("create proposal: %v", err)
|
||||
}
|
||||
if _, err := RespondToProposal(ctx, db, "timeout-player-a", proposal.ProposalID, "timeout-accept-a-0001", true, 0, now.Add(time.Second)); err != nil {
|
||||
t.Fatalf("accept proposal: %v", err)
|
||||
}
|
||||
|
||||
// Nobody ever responds; recover the proposal well after its 10s window,
|
||||
// exactly as a client reconnecting after missing the expiry event would.
|
||||
// player-b never responds; recover well after the response window.
|
||||
afterExpiry := now.Add(domain.ProposalWindow + time.Second)
|
||||
recovered, err := GetProposal(ctx, db, "timeout-player-a", proposal.ProposalID, afterExpiry)
|
||||
if err != nil {
|
||||
@@ -725,18 +742,18 @@ func TestPostgreSQLProposalTimeoutRequeuesEveryParticipant(t *testing.T) {
|
||||
}
|
||||
|
||||
var stateA, stateB string
|
||||
var expiresB time.Time
|
||||
if err := db.QueryRow(`SELECT state FROM queue_tickets WHERE ticket_id = 'timeout-ticket-0'`).Scan(&stateA); err != nil {
|
||||
var expiresA time.Time
|
||||
if err := db.QueryRow(`SELECT state, expires_at FROM queue_tickets WHERE ticket_id = 'timeout-ticket-0'`).Scan(&stateA, &expiresA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT state, expires_at FROM queue_tickets WHERE ticket_id = 'timeout-ticket-1'`).Scan(&stateB, &expiresB); err != nil {
|
||||
if err := db.QueryRow(`SELECT state FROM queue_tickets WHERE ticket_id = 'timeout-ticket-1'`).Scan(&stateB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stateA != "QUEUED" || stateB != "QUEUED" {
|
||||
t.Fatalf("timed-out participants left stranded: a=%s b=%s", stateA, stateB)
|
||||
if stateA != "QUEUED" || stateB != "EXPIRED" {
|
||||
t.Fatalf("timeout did not split accepted and offender tickets: a=%s b=%s", stateA, stateB)
|
||||
}
|
||||
if !expiresB.After(afterExpiry) {
|
||||
t.Fatalf("requeued ticket expiry %v was not refreshed forward from %v", expiresB, afterExpiry)
|
||||
if !expiresA.After(afterExpiry) {
|
||||
t.Fatalf("requeued ticket expiry %v was not refreshed forward from %v", expiresA, afterExpiry)
|
||||
}
|
||||
candidates, err := ListQueuedCandidates(ctx, db, domain.Casual, afterExpiry, 10)
|
||||
if err != nil {
|
||||
@@ -746,8 +763,72 @@ func TestPostgreSQLProposalTimeoutRequeuesEveryParticipant(t *testing.T) {
|
||||
for _, candidate := range candidates {
|
||||
found[candidate.PlayerID] = true
|
||||
}
|
||||
if !found["timeout-player-a"] || !found["timeout-player-b"] {
|
||||
t.Fatalf("requeued players are not visible to the matcher: %+v", candidates)
|
||||
if !found["timeout-player-a"] || found["timeout-player-b"] {
|
||||
t.Fatalf("matcher did not isolate timeout offender: %+v", candidates)
|
||||
}
|
||||
var cooldownEnd time.Time
|
||||
if err := db.QueryRow(`SELECT ends_at FROM penalties WHERE player_id = 'timeout-player-b' AND kind = 'PROPOSAL_TIMEOUT'`).Scan(&cooldownEnd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := afterExpiry.Add(60 * time.Second); !cooldownEnd.Equal(want) {
|
||||
t.Fatalf("timeout cooldown end = %v, want %v", cooldownEnd, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A late response must report a closed proposal only after committing the
|
||||
// expiry recovery. Returning that domain error from inside RunSerializable
|
||||
// used to roll every recovery write back.
|
||||
func TestPostgreSQLLateProposalResponseCommitsExpiryRecovery(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
ctx := context.Background()
|
||||
for _, player := range []string{"late-player-a", "late-player-b"} {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $1)`, player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i, player := range []string{"late-player-a", "late-player-b"} {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ($1, $2, 'casual', 'QUEUED', 'integration-build', 1, $3, $4)`, fmt.Sprintf("late-ticket-%d", i), player, now, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
proposal, err := domain.NewProposal("late-proposal", domain.Casual, []string{"late-player-a", "late-player-b"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreateProposal(ctx, db, proposal, map[string]string{"late-player-a": "late-ticket-0", "late-player-b": "late-ticket-1"}, now); err != nil {
|
||||
t.Fatalf("create proposal: %v", err)
|
||||
}
|
||||
|
||||
late := now.Add(domain.ProposalWindow + time.Second)
|
||||
_, err = RespondToProposal(ctx, db, "late-player-a", proposal.ProposalID, "late-response-a-0001", true, 0, late)
|
||||
if !errors.Is(err, domain.ErrProposalClosed) {
|
||||
t.Fatalf("late response error = %v, want ErrProposalClosed", err)
|
||||
}
|
||||
var proposalState, ticketA, ticketB string
|
||||
if err := db.QueryRow(`SELECT state FROM proposals WHERE proposal_id = 'late-proposal'`).Scan(&proposalState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT state FROM queue_tickets WHERE ticket_id = 'late-ticket-0'`).Scan(&ticketA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT state FROM queue_tickets WHERE ticket_id = 'late-ticket-1'`).Scan(&ticketB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proposalState != "EXPIRED" || ticketA != "EXPIRED" || ticketB != "EXPIRED" {
|
||||
t.Fatalf("late recovery was not committed: proposal=%s tickets=%s,%s", proposalState, ticketA, ticketB)
|
||||
}
|
||||
var penalties, idempotencyRows int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM penalties WHERE kind = 'PROPOSAL_TIMEOUT' AND player_id IN ('late-player-a', 'late-player-b')`).Scan(&penalties); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM idempotency_keys WHERE scope = $1 AND idempotency_key = 'late-response-a-0001'`, ProposalResponseIdempotencyScope).Scan(&idempotencyRows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if penalties != 2 || idempotencyRows != 0 {
|
||||
t.Fatalf("late recovery side effects: penalties=%d idempotency_rows=%d", penalties, idempotencyRows)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,22 +20,24 @@ WHERE proposal_id = $1 AND state = 'OPEN' AND expires_at <= $2`
|
||||
const ProposalParticipantExpireSQL = `UPDATE proposal_participants
|
||||
SET response = 'TIMED_OUT', responded_at = $2
|
||||
WHERE proposal_id = $1 AND response = 'PENDING'
|
||||
AND EXISTS (SELECT 1 FROM proposals WHERE proposals.proposal_id = proposal_participants.proposal_id AND proposals.expires_at <= $2)`
|
||||
AND EXISTS (SELECT 1 FROM proposals WHERE proposals.proposal_id = proposal_participants.proposal_id
|
||||
AND proposals.state = 'EXPIRED' AND proposals.expires_at <= $2)`
|
||||
|
||||
// ProposalExpireRequeueSQL is the timeout sibling of
|
||||
// ProposalDeclineRequeueSQL: a proposal that simply times out (no unanimous
|
||||
// response inside the 10s window) leaves any participant still holding a
|
||||
// PROPOSED ticket exactly as stranded as an explicit decline does, and for
|
||||
// the identical reason -- nothing else ever moves a PROPOSED ticket back to
|
||||
// QUEUED. The `state = 'EXPIRED'` guard makes this safe to call
|
||||
// unconditionally right after ProposalExpireSQL: it's a no-op on a proposal
|
||||
// that was already OPEN and stays OPEN (nothing to requeue) or one that was
|
||||
// already EXPIRED on a prior pass (its participants' tickets, if any were
|
||||
// still PROPOSED, were already requeued then).
|
||||
// ProposalExpireRequeueSQL preserves queue precedence only for participants
|
||||
// who accepted. Participants who did not respond are offenders and their
|
||||
// tickets are terminated separately by ProposalTimeoutTicketExpireSQL.
|
||||
const ProposalExpireRequeueSQL = `UPDATE queue_tickets q
|
||||
SET state = 'QUEUED', expires_at = $2, revision = revision + 1
|
||||
FROM proposal_participants pp
|
||||
WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'
|
||||
AND pp.response = 'ACCEPTED'
|
||||
AND EXISTS (SELECT 1 FROM proposals WHERE proposals.proposal_id = $1 AND proposals.state = 'EXPIRED')`
|
||||
|
||||
const ProposalTimeoutTicketExpireSQL = `UPDATE queue_tickets q
|
||||
SET state = 'EXPIRED', revision = revision + 1
|
||||
FROM proposal_participants pp
|
||||
WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'
|
||||
AND pp.response = 'TIMED_OUT'
|
||||
AND EXISTS (SELECT 1 FROM proposals WHERE proposals.proposal_id = $1 AND proposals.state = 'EXPIRED')`
|
||||
|
||||
const OpenProposalForCancelledTicketSQL = `SELECT pp.proposal_id
|
||||
@@ -47,8 +49,8 @@ WHERE pp.ticket_id = $1 AND pp.player_id = $2 AND p.state = 'OPEN'`
|
||||
// immediately when one of its participants cancels their own queue ticket
|
||||
// directly, rather than leaving every other participant to wait out the
|
||||
// full response window for something the system already knows can't happen
|
||||
// -- ProposalExpireRequeueSQL would eventually rescue them anyway, but not
|
||||
// for up to ProposalWindow's full duration for no reason. Must run inside
|
||||
// -- expiry recovery would eventually release them anyway, but not for up to
|
||||
// ProposalWindow's full duration for no reason. Must run inside
|
||||
// the same transaction as the ticket cancel itself; a no-op if the ticket
|
||||
// wasn't part of any currently-OPEN proposal.
|
||||
func CascadeCancelToOpenProposal(ctx context.Context, tx *sql.Tx, ticketID, playerID string, now time.Time) error {
|
||||
@@ -63,7 +65,7 @@ func CascadeCancelToOpenProposal(ctx context.Context, tx *sql.Tx, ticketID, play
|
||||
if _, err := tx.ExecContext(ctx, ProposalDeclineSQL, proposalID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow))
|
||||
_, err = tx.ExecContext(ctx, ProposalAbortRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -89,6 +91,9 @@ FROM idempotency_keys
|
||||
WHERE scope = $1 AND idempotency_key = $2
|
||||
FOR UPDATE`
|
||||
|
||||
const ProposalResponseIdempotencyDeleteSQL = `DELETE FROM idempotency_keys
|
||||
WHERE scope = $1 AND idempotency_key = $2`
|
||||
|
||||
const ProposalLockSQL = `SELECT playlist, state, revision, expires_at
|
||||
FROM proposals
|
||||
WHERE proposal_id = $1
|
||||
@@ -115,21 +120,33 @@ const ProposalDeclineSQL = `UPDATE proposals
|
||||
SET state = 'DECLINED', revision = revision + 1
|
||||
WHERE proposal_id = $1 AND state = 'OPEN'`
|
||||
|
||||
// ProposalDeclineRequeueSQL requeues every participant's ticket, including
|
||||
// the decliner's own. The durable decline penalty separately prevents that
|
||||
// player from immediately creating a replacement ticket; leaving this ticket
|
||||
// at PROPOSED would not implement a cooldown, it would strand the player and
|
||||
// hide the ticket from the matcher.
|
||||
const ProposalDeclineActorCancelSQL = `UPDATE queue_tickets q
|
||||
SET state = 'CANCELLED', revision = revision + 1
|
||||
FROM proposal_participants pp
|
||||
WHERE pp.proposal_id = $1 AND pp.player_id = $2
|
||||
AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'`
|
||||
|
||||
// ProposalDeclineRequeueSQL preserves the original queue precedence of every
|
||||
// innocent participant while terminating the declining player's ticket.
|
||||
const ProposalDeclineRequeueSQL = `UPDATE queue_tickets q
|
||||
SET state = 'QUEUED', expires_at = $2, revision = revision + 1
|
||||
FROM proposal_participants pp
|
||||
WHERE pp.proposal_id = $1 AND pp.player_id <> $3
|
||||
AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'`
|
||||
|
||||
// ProposalAbortRequeueSQL is used when a participant has already cancelled
|
||||
// their own ticket. It requeues every remaining PROPOSED ticket; the cancelled
|
||||
// ticket cannot be selected by the state predicate.
|
||||
const ProposalAbortRequeueSQL = `UPDATE queue_tickets q
|
||||
SET state = 'QUEUED', expires_at = $2, revision = revision + 1
|
||||
FROM proposal_participants pp
|
||||
WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'`
|
||||
|
||||
const ProposalCooldownEventsSQL = `SELECT kind, starts_at
|
||||
FROM penalties
|
||||
WHERE player_id = $1 AND playlist = $2
|
||||
AND kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT')
|
||||
AND starts_at >= $3
|
||||
AND starts_at >= $3 AND starts_at <= $4
|
||||
ORDER BY starts_at`
|
||||
|
||||
const ProposalCooldownInsertSQL = `INSERT INTO penalties
|
||||
@@ -143,7 +160,7 @@ WHERE proposal_id = $1 AND response = 'TIMED_OUT' AND responded_at = $2
|
||||
ORDER BY player_id`
|
||||
|
||||
func recordProposalCooldown(ctx context.Context, tx *sql.Tx, playerID string, playlist domain.Playlist, proposalID, kind string, response domain.Response, now time.Time) error {
|
||||
rows, err := tx.QueryContext(ctx, ProposalCooldownEventsSQL, playerID, string(playlist), now.Add(-30*time.Minute))
|
||||
rows, err := tx.QueryContext(ctx, ProposalCooldownEventsSQL, playerID, string(playlist), now.Add(-30*time.Minute), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -162,6 +179,10 @@ func recordProposalCooldown(ctx context.Context, tx *sql.Tx, playerID string, pl
|
||||
events = append(events, domain.CooldownEvent{At: at, Playlist: playlist, Kind: response})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
events = append(events, domain.CooldownEvent{At: now, Playlist: playlist, Kind: response})
|
||||
@@ -235,6 +256,9 @@ func GetProposal(ctx context.Context, db *sql.DB, playerID, proposalID string, n
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalTimeoutTicketExpireSQL, proposalID); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
@@ -278,7 +302,9 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id
|
||||
}
|
||||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%t|%d", playerID, proposalID, accept, expectedRevision)))
|
||||
var proposal domain.Proposal
|
||||
closed := false
|
||||
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
||||
closed = false
|
||||
result, err := tx.ExecContext(ctx, ProposalResponseIdempotencyInsertSQL, ProposalResponseIdempotencyScope, idempotencyKey, digest[:], []byte("{}"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -321,14 +347,20 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id
|
||||
if err := recordProposalTimeoutCooldowns(ctx, tx, proposalID, domain.Playlist(playlist), now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalTimeoutTicketExpireSQL, proposalID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil {
|
||||
return err
|
||||
}
|
||||
if !now.Before(expiresAt) {
|
||||
return domain.ErrProposalClosed
|
||||
}
|
||||
if state != string(domain.Open) || !now.Before(expiresAt) {
|
||||
return domain.ErrProposalClosed
|
||||
// Commit any expiry recovery above, but do not retain a placeholder
|
||||
// idempotency result for a mutation that was rejected as closed.
|
||||
if _, err := tx.ExecContext(ctx, ProposalResponseIdempotencyDeleteSQL, ProposalResponseIdempotencyScope, idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
return nil
|
||||
}
|
||||
if revision != expectedRevision {
|
||||
return domain.ErrStaleRevision
|
||||
@@ -374,7 +406,10 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id
|
||||
if err := recordProposalDeclineCooldown(ctx, tx, playerID, domain.Playlist(playlist), proposalID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow))
|
||||
if _, err = tx.ExecContext(ctx, ProposalDeclineActorCancelSQL, proposalID, playerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow), playerID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -411,5 +446,8 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id
|
||||
_, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, ProposalResponseIdempotencyScope, idempotencyKey, stored)
|
||||
return err
|
||||
})
|
||||
if err == nil && closed {
|
||||
return domain.Proposal{}, domain.ErrProposalClosed
|
||||
}
|
||||
return proposal, err
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
func TestProposalRecoverySQLBindsParticipantAndExpiresAtReadBoundary(t *testing.T) {
|
||||
for query, fragments := range map[string][]string{
|
||||
ProposalExpireSQL: {"state = 'OPEN'", "expires_at <= $2", "revision = revision + 1"},
|
||||
ProposalParticipantExpireSQL: {"response = 'PENDING'", "response = 'TIMED_OUT'", "proposals.expires_at <= $2"},
|
||||
ProposalParticipantExpireSQL: {"response = 'PENDING'", "response = 'TIMED_OUT'", "proposals.state = 'EXPIRED'", "proposals.expires_at <= $2"},
|
||||
ProposalRecoverySelectSQL: {"proposal_id = $1", "player_id = $2", "EXISTS"},
|
||||
ProposalParticipantsSelectSQL: {"proposal_id = $1", "ORDER BY player_id"},
|
||||
ProposalResponseIdempotencyInsertSQL: {"ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
||||
ProposalResponseIdempotencyDeleteSQL: {"DELETE FROM idempotency_keys", "scope = $1", "idempotency_key = $2"},
|
||||
ProposalLockSQL: {"proposal_id = $1", "FOR UPDATE"},
|
||||
ProposalParticipantLockSQL: {"proposal_id = $1", "player_id = $2", "FOR UPDATE"},
|
||||
ProposalParticipantRespondSQL: {"response = 'PENDING'", "responded_at"},
|
||||
ProposalRevisionBumpSQL: {"revision = revision + 1", "state = 'OPEN'"},
|
||||
ProposalDeclineRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "proposal_participants"},
|
||||
ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "state = 'EXPIRED'"},
|
||||
ProposalCooldownEventsSQL: {"kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT')", "starts_at >= $3", "ORDER BY starts_at"},
|
||||
ProposalDeclineActorCancelSQL: {"SET state = 'CANCELLED'", "player_id = $2", "state = 'PROPOSED'"},
|
||||
ProposalDeclineRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "player_id <> $3"},
|
||||
ProposalAbortRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "proposal_participants"},
|
||||
ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "response = 'ACCEPTED'", "state = 'EXPIRED'"},
|
||||
ProposalTimeoutTicketExpireSQL: {"SET state = 'EXPIRED'", "response = 'TIMED_OUT'", "state = 'PROPOSED'"},
|
||||
ProposalCooldownEventsSQL: {"kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT')", "starts_at >= $3", "starts_at <= $4", "ORDER BY starts_at"},
|
||||
ProposalCooldownInsertSQL: {"INSERT INTO penalties", "starts_at", "ends_at", "ON CONFLICT (penalty_id) DO NOTHING"},
|
||||
ProposalTimedOutParticipantsSQL: {"response = 'TIMED_OUT'", "responded_at = $2", "ORDER BY player_id"},
|
||||
OpenProposalForCancelledTicketSQL: {"proposal_participants", "state = 'OPEN'"},
|
||||
|
||||
Reference in New Issue
Block a user