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;
///
/// EF Core implementation of . See the
/// interface for the monotonic-upsert contract; this class adds notes on the
/// data-access strategy used by each method.
///
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 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 _logger;
public SiteCallAuditRepository(ScadaLinkDbContext context, ILogger? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger.Instance;
}
///
/// Two-step: IF NOT EXISTS INSERT then conditional UPDATE with
/// an inline CASE rank comparison. Both go through
///
/// 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.
///
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);
}
///
/// Single FindAsync against the PK. Returns null for unknown ids.
///
public async Task GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
return await _context.Set().FindAsync(new object?[] { id }, ct);
}
///
/// Builds a parameterised SQL query against dbo.SiteCalls ordered by
/// (CreatedAtUtc DESC, TrackedOperationId DESC), 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
/// 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.
///
public async Task> 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()
.FromSqlInterpolated(sql)
.AsNoTracking()
.ToListAsync(ct);
return rows;
}
///
/// Deletes rows whose is non-null AND
/// strictly less than . Non-terminal rows are
/// never touched. Returns the number of rows removed.
///
public async Task 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";
///
/// Computes the global KPI snapshot with five server-side aggregate queries
/// against dbo.SiteCalls. No rows are materialised — every count is a
/// translated COUNT and the oldest-pending age is a translated
/// MIN(CreatedAtUtc). The Status and CreatedAtUtc/TerminalAtUtc
/// columns have no value converter, so the aggregates translate cleanly to
/// SQL Server (unlike the NotificationOutbox's DateTimeOffset-converted
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
/// TerminalAtUtc IS NULL — see the field comments above.
///
public async Task 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);
}
///
/// Computes the per-source-site KPI breakdown. The five counts are
/// GROUP BY SourceSite aggregates; the oldest-pending age is a
/// per-site MIN(CreatedAtUtc) 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 TerminalAtUtc IS NULL — see .
///
public async Task> 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();
}
/// Counts SiteCalls rows matching , grouped by source site.
private async Task> CountBySiteAsync(
System.Linq.Expressions.Expression> 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;
}
}