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:
@@ -79,4 +79,34 @@ public interface ISecuredWriteRepository
|
||||
string? verifierComment,
|
||||
DateTime decidedAtUtc,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically flips a row from <c>Pending</c> to <c>Expired</c>, stamping the
|
||||
/// expiry as the decision time, but ONLY if the row is still <c>Pending</c>.
|
||||
/// This is the multi-node-safe expiry primitive: the conditional
|
||||
/// <c>WHERE Status='Pending'</c> makes the Pending->Expired transition atomic at
|
||||
/// the row level, so two nodes sweeping the same overdue write concurrently
|
||||
/// produce exactly one winner. The expiry is a system decision — no verifier is
|
||||
/// recorded and <see cref="PendingSecuredWrite.VerifierUser"/> stays <c>null</c>.
|
||||
/// The loser observes <c>false</c> (the row was already decided or expired).
|
||||
/// </summary>
|
||||
/// <param name="id">Identity of the pending secured write.</param>
|
||||
/// <param name="expiredAtUtc">UTC instant the write was deemed expired.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A task resolving to <c>true</c> if this caller won the expiry (exactly one
|
||||
/// row transitioned), or <c>false</c> if the row was no longer <c>Pending</c>.
|
||||
/// </returns>
|
||||
Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of rows matching the optional <paramref name="status"/> and
|
||||
/// <paramref name="siteId"/> filters. A <c>null</c> filter argument matches every
|
||||
/// row. Mirrors <see cref="QueryAsync"/>'s filter composition without paging.
|
||||
/// </summary>
|
||||
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
|
||||
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the count of matching rows.</returns>
|
||||
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -109,6 +109,61 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Contains(all, p => p.Id == executedId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
|
||||
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
||||
|
||||
Assert.True(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
||||
// CAS: a second caller loses because the row is no longer Pending.
|
||||
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal("Expired", row!.Status);
|
||||
Assert.NotNull(row.DecidedAtUtc);
|
||||
Assert.Null(row.VerifierUser); // system decision, no verifier
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
|
||||
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
||||
Assert.True(await repo.TryMarkApprovedAsync(id, "ver.bob", null, DateTime.UtcNow));
|
||||
|
||||
// Already Approved -> the conditional WHERE Status='Pending' matches no row.
|
||||
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CountAsync_FiltersLikeQueryAsync()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
|
||||
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
||||
await repo.AddAsync(NewRow(siteId, status: "Pending"));
|
||||
|
||||
Assert.Equal(2, await repo.CountAsync(status: "Pending", siteId: siteId));
|
||||
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
|
||||
Reference in New Issue
Block a user