diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs index 5e632869..fe431e10 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs @@ -79,4 +79,34 @@ public interface ISecuredWriteRepository string? verifierComment, DateTime decidedAtUtc, CancellationToken ct = default); + + /// + /// Atomically flips a row from Pending to Expired, stamping the + /// expiry as the decision time, but ONLY if the row is still Pending. + /// This is the multi-node-safe expiry primitive: 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 winner. The expiry is a system decision — no verifier is + /// recorded and stays null. + /// The loser observes false (the row was already decided or expired). + /// + /// Identity of the pending secured write. + /// UTC instant the write was deemed expired. + /// Cancellation token. + /// + /// A task resolving to true if this caller won the expiry (exactly one + /// row transitioned), or false if the row was no longer Pending. + /// + Task TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default); + + /// + /// Returns the number of rows matching the optional and + /// filters. A null filter argument matches every + /// row. Mirrors 's filter composition without paging. + /// + /// Status filter; null matches every status. + /// Site id filter; null matches every site. + /// Cancellation token. + /// A task that resolves to the count of matching rows. + Task CountAsync(string? status, string? siteId, CancellationToken ct = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs index 94b3f7d2..cab9d433 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs @@ -106,4 +106,43 @@ WHERE Id = {id} return rowsAffected == 1; } + + /// + public async Task 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; + } + + /// + public async Task CountAsync(string? status, string? siteId, CancellationToken ct = default) + { + IQueryable query = _context.Set() + .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); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs index b9528ecf..3ccff9fa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs @@ -109,6 +109,61 @@ public class SecuredWriteRepositoryTests : IClassFixture 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()