feat(config-db): secured-write TryMarkExpiredAsync CAS + CountAsync (arch-review S2/P3)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:00:23 -04:00
parent 5cfa85ea91
commit 0fad6817c2
3 changed files with 124 additions and 0 deletions
@@ -106,4 +106,43 @@ WHERE Id = {id}
return rowsAffected == 1;
}
/// <inheritdoc />
public async Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default)
{
// Single-statement compare-and-swap: the conditional WHERE Status='Pending'
// makes the Pending->Expired transition atomic at the row level, so two nodes
// sweeping the same overdue write concurrently produce exactly one
// rowsAffected==1 (the winner) and one rowsAffected==0 (the loser). Expiry is
// a system decision — VerifierUser is left NULL. Same conditional-update
// pattern as TryMarkApprovedAsync.
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.PendingSecuredWrites
SET Status = 'Expired',
DecidedAtUtc = {expiredAtUtc}
WHERE Id = {id}
AND Status = 'Pending';",
ct);
return rowsAffected == 1;
}
/// <inheritdoc />
public async Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default)
{
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
.AsNoTracking();
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(p => p.Status == status);
}
if (!string.IsNullOrWhiteSpace(siteId))
{
query = query.Where(p => p.SiteId == siteId);
}
return await query.CountAsync(ct);
}
}