From 6d3490da147bcbfdbb2e3782641df376b8740070 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:44:43 +0100 Subject: [PATCH] fix(server): gate proposal participant timeout on actual expiry ProposalParticipantExpireSQL marked every PENDING participant on a proposal TIMED_OUT unconditionally -- it took a proposal_id and 'now' but never actually compared 'now' against the proposal's expires_at, unlike its sibling ProposalExpireSQL (which does gate on 'expires_at <= $2'). Both GetProposal and RespondToProposal run this statement on every call as a recovery step, so the very first RespondToProposal for any proposal timed out every participant (including the one about to respond) before checking their response, then rejected the real accept/decline with ErrConflict. Add the same expiry gate via an EXISTS against proposals.expires_at, matching ProposalExpireSQL's own condition, and update the SQL-fragment test to assert the gate is present. Verified end to end against a real PostgreSQL instance: TestPostgreSQLProposalClaimAndResponseAreAtomic now passes a two-participant accept/accept sequence that previously failed on the first response. --- server/store/proposal_recovery_sql.go | 3 ++- server/store/proposal_recovery_sql_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/store/proposal_recovery_sql.go b/server/store/proposal_recovery_sql.go index a19006c2..22903caf 100644 --- a/server/store/proposal_recovery_sql.go +++ b/server/store/proposal_recovery_sql.go @@ -18,7 +18,8 @@ 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'` +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)` const ProposalRecoverySelectSQL = `SELECT proposal_id, playlist, state, revision, expires_at FROM proposals diff --git a/server/store/proposal_recovery_sql_test.go b/server/store/proposal_recovery_sql_test.go index 90286dc6..13e2281a 100644 --- a/server/store/proposal_recovery_sql_test.go +++ b/server/store/proposal_recovery_sql_test.go @@ -8,7 +8,7 @@ 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'"}, + ProposalParticipantExpireSQL: {"response = 'PENDING'", "response = 'TIMED_OUT'", "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"},