213 lines
7.8 KiB
C#
213 lines
7.8 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
/// <summary>
|
|
/// EF Core implementation of <see cref="ISecuredWriteRepository"/> over the central
|
|
/// <c>PendingSecuredWrites</c> table. Mirrors the
|
|
/// <c>SiteCallAuditRepository</c> data-access shape: plain tracked EF reads/writes
|
|
/// against the shared <see cref="ScadaBridgeDbContext"/>, no raw SQL needed.
|
|
/// </summary>
|
|
public class SecuredWriteRepository : ISecuredWriteRepository
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SecuredWriteRepository"/> class.
|
|
/// </summary>
|
|
/// <param name="context">The EF Core database context.</param>
|
|
public SecuredWriteRepository(ScadaBridgeDbContext context)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<long> AddAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(securedWrite);
|
|
|
|
await _context.Set<PendingSecuredWrite>().AddAsync(securedWrite, ct);
|
|
await _context.SaveChangesAsync(ct);
|
|
return securedWrite.Id;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<PendingSecuredWrite?> GetAsync(long id, CancellationToken ct = default)
|
|
{
|
|
return await _context.Set<PendingSecuredWrite>()
|
|
.FindAsync(new object?[] { id }, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UpdateAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(securedWrite);
|
|
|
|
// The caller hands back the same tracked instance it read; Update covers the
|
|
// detached case too (re-attaches and marks every column modified).
|
|
_context.Set<PendingSecuredWrite>().Update(securedWrite);
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
|
|
string? status,
|
|
string? siteId,
|
|
int skip,
|
|
int take,
|
|
IReadOnlyCollection<string>? permittedSiteIds = null,
|
|
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);
|
|
}
|
|
|
|
if (permittedSiteIds is not null)
|
|
{
|
|
query = query.Where(p => permittedSiteIds.Contains(p.SiteId));
|
|
}
|
|
|
|
return await query
|
|
.OrderByDescending(p => p.SubmittedAtUtc)
|
|
.ThenByDescending(p => p.Id)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> TryMarkApprovedAsync(
|
|
long id,
|
|
string verifierUser,
|
|
string? verifierComment,
|
|
DateTime decidedAtUtc,
|
|
CancellationToken ct = default)
|
|
{
|
|
// Single-statement compare-and-swap: the conditional WHERE Status='Pending'
|
|
// makes the Pending->Approved transition atomic at the row level, so two
|
|
// verifiers approving concurrently produce exactly one rowsAffected==1 (the
|
|
// winner) and one rowsAffected==0 (the loser). Parameterised via
|
|
// ExecuteSqlInterpolatedAsync — same raw-SQL conditional-update pattern as
|
|
// SiteCallAuditRepository's upsert-on-newer-status path.
|
|
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
|
|
$@"UPDATE dbo.PendingSecuredWrites
|
|
SET Status = 'Approved',
|
|
VerifierUser = {verifierUser},
|
|
VerifierComment = {verifierComment},
|
|
DecidedAtUtc = {decidedAtUtc}
|
|
WHERE Id = {id}
|
|
AND Status = 'Pending';",
|
|
ct);
|
|
|
|
return rowsAffected == 1;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> TryMarkRejectedAsync(
|
|
long id,
|
|
string verifierUser,
|
|
string? verifierComment,
|
|
DateTime decidedAtUtc,
|
|
CancellationToken ct = default)
|
|
{
|
|
// Single-statement compare-and-swap, mirroring TryMarkApprovedAsync exactly.
|
|
// A blind full-row UpdateAsync here used to be able to land AFTER a concurrent
|
|
// approve had won its own CAS and while its multi-second WriteTagAsync relay was
|
|
// still in flight — stamping the row Rejected even though the plant write was
|
|
// physically executing, i.e. falsifying the two-person-control record. The conditional WHERE Status='Pending' makes
|
|
// reject win only from Pending, so approve and reject can never both transition
|
|
// one row. Only the columns the reject transition owns are written.
|
|
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
|
|
$@"UPDATE dbo.PendingSecuredWrites
|
|
SET Status = 'Rejected',
|
|
VerifierUser = {verifierUser},
|
|
VerifierComment = {verifierComment},
|
|
DecidedAtUtc = {decidedAtUtc}
|
|
WHERE Id = {id}
|
|
AND Status = 'Pending';",
|
|
ct);
|
|
|
|
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,
|
|
IReadOnlyCollection<string>? permittedSiteIds = null,
|
|
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);
|
|
}
|
|
|
|
if (permittedSiteIds is not null)
|
|
{
|
|
query = query.Where(p => permittedSiteIds.Contains(p.SiteId));
|
|
}
|
|
|
|
return await query.CountAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, int batchSize, CancellationToken ct = default)
|
|
{
|
|
if (batchSize <= 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
// Bounded set-based delete (DELETE ... WHERE Id IN (SELECT TOP(n) ...)): never a
|
|
// whole-table delete in one statement, so a first purge of a long-lived table cannot
|
|
// hold a large lock. Pending is excluded — it is expired first, then purged later.
|
|
return await _context.Set<PendingSecuredWrite>()
|
|
.Where(p => TerminalStatuses.Contains(p.Status)
|
|
&& (p.ExecutedAtUtc ?? p.DecidedAtUtc ?? p.SubmittedAtUtc) < olderThanUtc)
|
|
.OrderBy(p => p.Id)
|
|
.Take(batchSize)
|
|
.ExecuteDeleteAsync(ct);
|
|
}
|
|
|
|
private static readonly string[] TerminalStatuses = ["Approved", "Rejected", "Expired", "Executed", "Failed"];
|
|
}
|