384 lines
18 KiB
C#
384 lines
18 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Audit;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
|
|
|
/// <summary>
|
|
/// EF Core implementation of <see cref="ISiteCallAuditRepository"/>. See the
|
|
/// interface for the monotonic-upsert contract; this class adds notes on the
|
|
/// data-access strategy used by each method.
|
|
/// </summary>
|
|
public class SiteCallAuditRepository : ISiteCallAuditRepository
|
|
{
|
|
// SQL Server duplicate-key error numbers, identical to the AuditLogRepository
|
|
// race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint
|
|
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window
|
|
// and the loser surfaces as one of these; monotonic-upsert semantics demand
|
|
// we swallow them.
|
|
private const int SqlErrorUniqueIndexViolation = 2601;
|
|
private const int SqlErrorPrimaryKeyViolation = 2627;
|
|
|
|
// Monotonic status ordering. Lower rank wins on tie (same-rank upserts are
|
|
// no-ops, including terminal-over-terminal). Spec from Bundle B3 plan:
|
|
// Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
|
|
private static readonly Dictionary<string, int> StatusRank = new(StringComparer.Ordinal)
|
|
{
|
|
["Submitted"] = 0,
|
|
["Forwarded"] = 1,
|
|
["Attempted"] = 2,
|
|
["Skipped"] = 2,
|
|
["Delivered"] = 3,
|
|
["Failed"] = 3,
|
|
["Parked"] = 3,
|
|
["Discarded"] = 3,
|
|
};
|
|
|
|
private readonly ScadaLinkDbContext _context;
|
|
private readonly ILogger<SiteCallAuditRepository> _logger;
|
|
|
|
public SiteCallAuditRepository(ScadaLinkDbContext context, ILogger<SiteCallAuditRepository>? logger = null)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
_logger = logger ?? NullLogger<SiteCallAuditRepository>.Instance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two-step: <c>IF NOT EXISTS INSERT</c> then conditional <c>UPDATE</c> with
|
|
/// an inline <c>CASE</c> rank comparison. Both go through
|
|
/// <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>
|
|
/// so the change tracker is bypassed and the value-converted PK column is
|
|
/// written as the canonical "D"-format GUID string. Duplicate-key violations
|
|
/// from the insert race are swallowed.
|
|
/// </summary>
|
|
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
|
{
|
|
if (siteCall is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(siteCall));
|
|
}
|
|
|
|
var idText = siteCall.TrackedOperationId.Value.ToString("D");
|
|
var incomingRank = GetRankOrThrow(siteCall.Status);
|
|
|
|
// Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync
|
|
// this is check-then-act so a duplicate-key violation may surface under
|
|
// concurrent inserts on the same id — caught + logged at Debug.
|
|
//
|
|
// SourceNode-stamping (Task 14): the column is included in the INSERT
|
|
// column list / VALUES so a fresh row carries the originating node
|
|
// name (node-a/node-b for site rows). A null SourceNode (legacy hosts
|
|
// / unstamped reconciled rows) writes NULL straight through.
|
|
try
|
|
{
|
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText})
|
|
INSERT INTO dbo.SiteCalls
|
|
(TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
|
|
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
|
|
VALUES
|
|
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount},
|
|
{siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
|
|
ct);
|
|
}
|
|
catch (SqlException ex) when (
|
|
ex.Number == SqlErrorUniqueIndexViolation
|
|
|| ex.Number == SqlErrorPrimaryKeyViolation)
|
|
{
|
|
_logger.LogDebug(
|
|
ex,
|
|
"SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.",
|
|
ex.Number,
|
|
idText);
|
|
}
|
|
|
|
// Step 2: monotonic update. The CASE expression maps the stored Status
|
|
// string to the same rank table the caller uses; we only mutate if the
|
|
// incoming rank is strictly greater. Same-rank (including
|
|
// terminal-over-terminal) is a no-op — first-write-wins at each rank.
|
|
//
|
|
// SourceNode-stamping (Task 14): SourceNode is updated via
|
|
// COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode
|
|
// when it is non-null, otherwise the stored value — so the column
|
|
// behaves protectively: a later packet that carries a null
|
|
// SourceNode (e.g. a reconciliation pull from an unstamped node)
|
|
// NEVER blanks out a value the first stamping packet set. A later
|
|
// packet that DOES carry a non-null SourceNode replaces the previous
|
|
// value — combined with the monotonic-rank guard this is
|
|
// "last-non-null-wins on rank advance", which lets a missing
|
|
// SourceNode be filled in later if Submit happened to be unstamped
|
|
// and an Attempt/Resolve carries the node identity. Within one
|
|
// lifecycle every packet should carry the same SourceNode value (one
|
|
// execution, one node) so the "overwrite" path is in practice
|
|
// idempotent.
|
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
|
$@"UPDATE dbo.SiteCalls
|
|
SET Status = {siteCall.Status},
|
|
RetryCount = {siteCall.RetryCount},
|
|
LastError = {siteCall.LastError},
|
|
HttpStatus = {siteCall.HttpStatus},
|
|
UpdatedAtUtc = {siteCall.UpdatedAtUtc},
|
|
TerminalAtUtc = {siteCall.TerminalAtUtc},
|
|
IngestedAtUtc = {siteCall.IngestedAtUtc},
|
|
SourceNode = COALESCE({siteCall.SourceNode}, SourceNode)
|
|
WHERE TrackedOperationId = {idText}
|
|
AND {incomingRank} > (CASE Status
|
|
WHEN 'Submitted' THEN 0
|
|
WHEN 'Forwarded' THEN 1
|
|
WHEN 'Attempted' THEN 2
|
|
WHEN 'Skipped' THEN 2
|
|
WHEN 'Delivered' THEN 3
|
|
WHEN 'Failed' THEN 3
|
|
WHEN 'Parked' THEN 3
|
|
WHEN 'Discarded' THEN 3
|
|
ELSE -1
|
|
END);",
|
|
ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single <c>FindAsync</c> against the PK. Returns <c>null</c> for unknown ids.
|
|
/// </summary>
|
|
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
|
|
{
|
|
return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a parameterised SQL query against <c>dbo.SiteCalls</c> ordered by
|
|
/// <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>, with keyset paging.
|
|
/// Raw SQL is used here (rather than LINQ) because EF Core 10 cannot
|
|
/// translate the lexicographic string comparison against the value-converted
|
|
/// <see cref="TrackedOperationId"/> column inside an expression tree — the
|
|
/// converter is applied to equality but not to inequality comparisons
|
|
/// against the underlying Guid. The keyset tiebreaker is varchar lex order,
|
|
/// which is deterministic and gives "no overlap, every row exactly once"
|
|
/// paging without depending on Guid byte ordering.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
|
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
|
|
{
|
|
if (filter is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(filter));
|
|
}
|
|
|
|
if (paging is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(paging));
|
|
}
|
|
|
|
// FormattableString interpolation parameterises every value (no concatenation)
|
|
// so this is injection-safe. EF Core resolves the parameter values, the
|
|
// composed sql is shaped to SQL Server's grammar and projected into the
|
|
// SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each
|
|
// optional predicate so a null filter field degrades to a no-op (matches
|
|
// every row) instead of branching at C# level into N variants.
|
|
var afterCreated = paging.AfterCreatedAtUtc;
|
|
var afterIdString = paging.AfterId?.Value.ToString("D");
|
|
var hasCursor = afterCreated is not null && afterIdString is not null;
|
|
|
|
var fromUtc = filter.FromUtc;
|
|
var toUtc = filter.ToUtc;
|
|
var stuckCutoff = filter.StuckCutoffUtc;
|
|
|
|
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
|
// is pushed into SQL here — both columns are plain (no value converter)
|
|
// and compose with the keyset cursor, so a StuckOnly page is honest:
|
|
// never under-filled with a non-null next cursor. Mirrors how
|
|
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
|
|
//
|
|
// SELECT-list maintenance: EF Core's FromSqlInterpolated requires every
|
|
// entity-tracked column to appear in the result set. Adding a new column
|
|
// to the SiteCall entity means extending the list below too — otherwise
|
|
// every read trips "The required column 'X' was not present" at runtime.
|
|
FormattableString sql = $@"
|
|
SELECT TOP ({paging.PageSize})
|
|
TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
|
|
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc
|
|
FROM dbo.SiteCalls
|
|
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
|
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
|
|
AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode})
|
|
AND ({filter.Status} IS NULL OR Status = {filter.Status})
|
|
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
|
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
|
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
|
|
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
|
|
AND ({(hasCursor ? 1 : 0)} = 0
|
|
OR CreatedAtUtc < {afterCreated}
|
|
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
|
|
ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
|
|
|
|
var rows = await _context.Set<SiteCall>()
|
|
.FromSqlInterpolated(sql)
|
|
.AsNoTracking()
|
|
.ToListAsync(ct);
|
|
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes rows whose <see cref="SiteCall.TerminalAtUtc"/> is non-null AND
|
|
/// strictly less than <paramref name="olderThanUtc"/>. Non-terminal rows are
|
|
/// never touched. Returns the number of rows removed.
|
|
/// </summary>
|
|
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
|
{
|
|
return await _context.Database.ExecuteSqlInterpolatedAsync(
|
|
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
|
|
ct);
|
|
}
|
|
|
|
// Terminal status string literals for the interval-throughput KPIs. The
|
|
// Status column is a plain varchar (no value converter), so these compare
|
|
// directly in translated SQL.
|
|
//
|
|
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
|
|
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
|
|
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
|
|
// KPI section uses. There is therefore no Status string that means
|
|
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
|
|
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
|
|
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
|
|
// null while still active"). All buffered / stuck / oldest-pending counts
|
|
// below key off TerminalAtUtc, not Status.
|
|
private const string StatusParked = "Parked";
|
|
private const string StatusDelivered = "Delivered";
|
|
private const string StatusFailed = "Failed";
|
|
|
|
/// <summary>
|
|
/// Computes the global KPI snapshot with five server-side aggregate queries
|
|
/// against <c>dbo.SiteCalls</c>. No rows are materialised — every count is a
|
|
/// translated <c>COUNT</c> and the oldest-pending age is a translated
|
|
/// <c>MIN(CreatedAtUtc)</c>. The <c>Status</c> and <c>CreatedAtUtc</c>/<c>TerminalAtUtc</c>
|
|
/// columns have no value converter, so the aggregates translate cleanly to
|
|
/// SQL Server (unlike the NotificationOutbox's <c>DateTimeOffset</c>-converted
|
|
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
|
|
/// <c>TerminalAtUtc IS NULL</c> — see the field comments above.
|
|
/// </summary>
|
|
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
var bufferedCount = await _context.SiteCalls
|
|
.CountAsync(s => s.TerminalAtUtc == null, ct);
|
|
|
|
var parkedCount = await _context.SiteCalls
|
|
.CountAsync(s => s.Status == StatusParked, ct);
|
|
|
|
var failedLastInterval = await _context.SiteCalls
|
|
.CountAsync(s => s.Status == StatusFailed
|
|
&& s.TerminalAtUtc != null
|
|
&& s.TerminalAtUtc >= intervalSince, ct);
|
|
|
|
var deliveredLastInterval = await _context.SiteCalls
|
|
.CountAsync(s => s.Status == StatusDelivered
|
|
&& s.TerminalAtUtc != null
|
|
&& s.TerminalAtUtc >= intervalSince, ct);
|
|
|
|
var stuckCount = await _context.SiteCalls
|
|
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
|
|
|
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
|
|
|
|
TimeSpan? oldestPendingAge = null;
|
|
if (await nonTerminal.AnyAsync(ct))
|
|
{
|
|
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
|
|
oldestPendingAge = now - oldestCreatedAt;
|
|
}
|
|
|
|
return new SiteCallKpiSnapshot(
|
|
BufferedCount: bufferedCount,
|
|
ParkedCount: parkedCount,
|
|
FailedLastInterval: failedLastInterval,
|
|
DeliveredLastInterval: deliveredLastInterval,
|
|
OldestPendingAge: oldestPendingAge,
|
|
StuckCount: stuckCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the per-source-site KPI breakdown. The five counts are
|
|
/// <c>GROUP BY SourceSite</c> aggregates; the oldest-pending age is a
|
|
/// per-site <c>MIN(CreatedAtUtc)</c> over the (bounded) non-terminal set —
|
|
/// all run server-side. A site appears in the result only if it has at
|
|
/// least one row matched by one of the count queries. "Buffered" / "stuck"
|
|
/// key off <c>TerminalAtUtc IS NULL</c> — see <see cref="ComputeKpisAsync"/>.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
|
|
|
|
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
|
|
|
|
var failed = await CountBySiteAsync(
|
|
s => s.Status == StatusFailed
|
|
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
|
|
|
var delivered = await CountBySiteAsync(
|
|
s => s.Status == StatusDelivered
|
|
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
|
|
|
var stuck = await CountBySiteAsync(
|
|
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
|
|
|
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
|
|
var oldest = (await _context.SiteCalls
|
|
.Where(s => s.TerminalAtUtc == null)
|
|
.GroupBy(s => s.SourceSite)
|
|
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Site, x => x.Oldest);
|
|
|
|
var siteIds = buffered.Keys
|
|
.Concat(parked.Keys).Concat(failed.Keys)
|
|
.Concat(delivered.Keys).Concat(stuck.Keys)
|
|
.Distinct()
|
|
.OrderBy(s => s, StringComparer.Ordinal);
|
|
|
|
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
|
|
SourceSite: site,
|
|
BufferedCount: buffered.GetValueOrDefault(site),
|
|
ParkedCount: parked.GetValueOrDefault(site),
|
|
FailedLastInterval: failed.GetValueOrDefault(site),
|
|
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
|
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
|
? now - createdAt
|
|
: null,
|
|
StuckCount: stuck.GetValueOrDefault(site))).ToList();
|
|
}
|
|
|
|
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
|
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
|
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
|
|
CancellationToken ct)
|
|
{
|
|
return await _context.SiteCalls
|
|
.Where(predicate)
|
|
.GroupBy(s => s.SourceSite)
|
|
.Select(g => new { Site = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
|
|
}
|
|
|
|
private static int GetRankOrThrow(string status)
|
|
{
|
|
if (!StatusRank.TryGetValue(status, out var rank))
|
|
{
|
|
throw new ArgumentException(
|
|
$"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.",
|
|
nameof(status));
|
|
}
|
|
return rank;
|
|
}
|
|
}
|