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 | `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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue