NEO-151: Fix Postgres ListForPlayer read race with single CTE query

Bugbot: active and completed rows now load in one statement snapshot so
GET /contracts cannot return the same instance twice after completion.
pull/193/head
VinPropane 2026-06-28 11:17:21 -04:00
parent 50df06a3f9
commit d6ebc767e6
2 changed files with 29 additions and 43 deletions

View File

@ -53,6 +53,7 @@ None.
|----------|----------|---------|--------| |----------|----------|---------|--------|
| Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | | Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) |
| Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires | | Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires |
| Medium | `PostgresContractInstanceStore.ListForPlayer` | Active + completed read in two queries without snapshot — duplicate row if status flips mid-read | **Done.** — single CTE query for atomic list snapshot |
## Verification ## Verification

View File

@ -330,56 +330,41 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo
return []; return [];
} }
ContractInstanceState? active = null; // Single statement snapshot — avoids active+completed duplicate rows if status flips mid-read.
using (var activeCmd = new Npgsql.NpgsqlCommand( using var cmd = new Npgsql.NpgsqlCommand(
""" """
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at WITH active_row AS (
FROM contract_instance SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 0 AS section
WHERE player_id = @pid AND status = 'active' FROM contract_instance
LIMIT 1; WHERE player_id = @pid AND status = 'active'
""", LIMIT 1
conn)) ),
{ completed_rows AS (
activeCmd.Parameters.AddWithValue("pid", player); SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 1 AS section
using var reader = activeCmd.ExecuteReader();
if (reader.Read())
{
var snapshot = ReadSnapshot(reader);
if (snapshot.Status == ContractInstanceStatus.Active)
{
active = snapshot;
}
}
}
var completed = new List<ContractInstanceState>(maxCompletedRows);
if (maxCompletedRows > 0)
{
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at
FROM contract_instance FROM contract_instance
WHERE player_id = @pid AND status = 'completed' WHERE player_id = @pid AND status = 'completed'
ORDER BY issued_at DESC, contract_instance_id ASC ORDER BY issued_at DESC, contract_instance_id ASC
LIMIT @limit; LIMIT @limit
""", )
conn); SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at
cmd.Parameters.AddWithValue("pid", player); FROM (
cmd.Parameters.AddWithValue("limit", maxCompletedRows); SELECT * FROM active_row
using var reader = cmd.ExecuteReader(); UNION ALL
while (reader.Read()) SELECT * FROM completed_rows
{ ) AS listed
completed.Add(ReadSnapshot(reader)); ORDER BY section, issued_at DESC, contract_instance_id ASC;
} """,
} conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("limit", maxCompletedRows);
if (active is null) var result = new List<ContractInstanceState>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{ {
return completed; result.Add(ReadSnapshot(reader));
} }
var result = new List<ContractInstanceState>(1 + completed.Count) { active };
result.AddRange(completed);
return result; return result;
} }