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()