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