Bundle B3 of Audit Log #23 M3: data-access layer for the central SiteCalls table introduced in B1+B2. UpsertAsync is insert-if-not-exists then monotonic-status update so out-of-order telemetry, duplicate gRPC packets, and reconciliation pulls all converge on the same row without rolling state backward. - src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs: UpsertAsync (monotonic), GetAsync, QueryAsync, PurgeTerminalAsync. - src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs + SiteCallPaging.cs: filter (Channel/SourceSite/Status/Target/time range) and keyset paging cursor on (CreatedAtUtc DESC, TrackedOperationId DESC), mirrored on M1's AuditLog* equivalents. - src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs: raw-SQL InsertIfNotExists + conditional UPDATE with inline CASE rank compare (Submitted=0, Forwarded=1, Attempted/Skipped=2, terminal=3 — terminal statuses are mutually exclusive so e.g. Delivered cannot overwrite Parked). Duplicate-key violations (SQL 2601/2627) are swallowed at Debug, identical to AuditLogRepository's race-fix. QueryAsync uses FromSqlInterpolated because EF Core 10 cannot translate string.Compare against the value-converted TrackedOperationId column inside an expression tree. - ServiceCollectionExtensions wires the repository (scoped, after IAuditLogRepository). - 12 integration tests in tests/ScadaLink.ConfigurationDatabase.Tests/ Repositories/ (MsSqlMigrationFixture + [SkippableFact]): fresh insert, monotonic advance, older-status no-op, same-status no-op, terminal-over-terminal no-op, 50-way concurrent-insert race produces exactly one row, Get known/unknown, filter by site, keyset paging no overlap, purge terminal-and-old, purge keeps non-terminal-and-recent.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Operational-state data access for the central <c>SiteCalls</c> table
|
||||
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). One row per
|
||||
/// <see cref="TrackedOperationId"/>; sites remain the source of truth and this
|
||||
/// table is an eventually-consistent mirror fed by best-effort gRPC telemetry
|
||||
/// plus periodic reconciliation pulls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Unlike the partitioned append-only <c>AuditLog</c> (M1), this table holds
|
||||
/// mutable operational state. <see cref="UpsertAsync"/> is insert-if-not-exists
|
||||
/// then monotonic update — a status update with rank less than or equal to the
|
||||
/// stored status is a silent no-op so out-of-order telemetry, duplicate gRPC
|
||||
/// packets, and reconciliation pulls can all feed the same writer without
|
||||
/// rolling state backward.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Status rank for monotonic comparison (lower wins): <c>Submitted=0,
|
||||
/// Forwarded=1, Attempted=2, Skipped=2, Delivered=3, Failed=3, Parked=3,
|
||||
/// Discarded=3</c>. Terminal statuses share rank 3 and are mutually exclusive
|
||||
/// — an attempt to upsert e.g. <c>Delivered</c> over an existing <c>Parked</c>
|
||||
/// row is a no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISiteCallAuditRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts <paramref name="siteCall"/> if no row with the same
|
||||
/// <see cref="SiteCall.TrackedOperationId"/> exists; otherwise updates the
|
||||
/// existing row IF AND ONLY IF the incoming status' rank strictly exceeds
|
||||
/// the stored status' rank. Out-of-order / duplicate updates are silently
|
||||
/// dropped (monotonic forward-only progression).
|
||||
/// </summary>
|
||||
Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the row for the given id, or <c>null</c> if none exists.
|
||||
/// </summary>
|
||||
Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to <see cref="SiteCallPaging.PageSize"/> rows matching
|
||||
/// <paramref name="filter"/>, ordered by <c>(CreatedAtUtc DESC,
|
||||
/// TrackedOperationId DESC)</c>. Use keyset paging via
|
||||
/// <see cref="SiteCallPaging.AfterCreatedAtUtc"/> + <see cref="SiteCallPaging.AfterId"/>
|
||||
/// to fetch subsequent pages.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter,
|
||||
SiteCallPaging paging,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes terminal rows whose <see cref="SiteCall.TerminalAtUtc"/> is
|
||||
/// strictly older than <paramref name="olderThanUtc"/>. Non-terminal rows
|
||||
/// (TerminalAtUtc IS NULL) are NEVER purged. Returns the number of rows
|
||||
/// deleted.
|
||||
/// </summary>
|
||||
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
|
||||
}
|
||||
15
src/ScadaLink.Commons/Types/Audit/SiteCallPaging.cs
Normal file
15
src/ScadaLink.Commons/Types/Audit/SiteCallPaging.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Keyset paging cursor for
|
||||
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
|
||||
/// The repository orders by <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c> — newest
|
||||
/// calls first, with the strong-typed id breaking ties when two calls share an exact
|
||||
/// <c>CreatedAtUtc</c>. Callers pass the last row of the previous page back as
|
||||
/// <see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/> to fetch the next page.
|
||||
/// Both must be non-null together, or both null (first page).
|
||||
/// </summary>
|
||||
public sealed record SiteCallPaging(
|
||||
int PageSize,
|
||||
DateTime? AfterCreatedAtUtc = null,
|
||||
TrackedOperationId? AfterId = null);
|
||||
21
src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs
Normal file
21
src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
|
||||
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
|
||||
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
|
||||
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
|
||||
/// <c>>=</c> / <c><=</c> respectively. All filter fields are AND-combined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Channel / Status / SourceSite / Target are matched as exact strings — the
|
||||
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
|
||||
/// page exposes them as drop-down filters, not free-text search.
|
||||
/// </remarks>
|
||||
public sealed record SiteCallQueryFilter(
|
||||
string? Channel = null,
|
||||
string? SourceSite = null,
|
||||
string? Status = null,
|
||||
string? Target = null,
|
||||
DateTime? FromUtc = null,
|
||||
DateTime? ToUtc = null);
|
||||
Reference in New Issue
Block a user