5d075f1374
Six adversarial-review findings in the central SQL/ingest layer. F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16, Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind time and committed the mutilated row — silent, in an append-only store, with no PayloadTruncated flag — while the per-row and reconciliation paths sent the same value in full and let the server reject it with 2628. Bind at the value's own length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's derived column types and datetime2 precision). Design: reject everywhere, truncate nowhere — matching today's per-row behaviour. F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing the first packet of one TrackedOperationId (the cached dual-write and the reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and the loser then skipped its INSERT or swallowed a 2627 — dropping its Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to `IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed parameters so the intricate rank predicate exists in exactly one place (an untyped DateTime would bind as `datetime` and round the freshness tiebreaker). F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF; once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too. All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO` (own batch, so it is in force when the next batch parses), and the migration convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified live: the pre-fix script fails 1934 without -I, the fixed one applies. F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the injected repository, so tests drove one DbContext from the pass and a mailbox handler concurrently. Serialized at the CALL via a private SerializedRepository wrapper applied only by the test constructors, rather than running the pass on-mailbox: production keeps its PipeTo shape untouched, and the existing "a blocked drain does not stall ingest/query/KPI" regression tests stay meaningful (they would have been invalidated by suspending the mailbox). F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget expired, the per-row fallback reused the same expired token: N instant failures, N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the batch instead of once per row. F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was discarded, so an operator Retry/Discard of a notification the retention purge had already deleted reported success (the pre-ExecuteUpdate code threw DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the operator one-shots answer "notification not found" and emit no audit row for the action that did not happen, while the dispatcher logs a warning (its delivery already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since the write is out-of-band. Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths + boundary round-trip; concurrent first-write and already-created-by-another-writer upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback, a repository-concurrency detector for the SiteCallAudit passes, and vanished-row operator-path tests. The F1/F2/F4 regressions were each confirmed failing against the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit 66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
154 lines
9.1 KiB
C#
154 lines
9.1 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
|
|
/// <summary>
|
|
/// Data access for the central notification outbox — the queue of <see cref="Notification"/>
|
|
/// rows the outbox actor drains, retries, and audits. Distinct from
|
|
/// <see cref="INotificationRepository"/>, which manages notification list configuration.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Persistence model: <see cref="InsertIfNotExistsAsync"/> and <see cref="UpdateAsync"/> commit
|
|
/// internally, so each call is its own transaction — suited to the outbox actor committing one
|
|
/// row's status transition at a time. The standalone <see cref="SaveChangesAsync"/> is available
|
|
/// for callers that stage multiple changes and want to flush them together.
|
|
/// </remarks>
|
|
public interface INotificationOutboxRepository
|
|
{
|
|
/// <summary>
|
|
/// Inserts <paramref name="n"/> only if no row with the same
|
|
/// <see cref="Notification.NotificationId"/> exists. Returns <c>true</c> when a new
|
|
/// row was inserted, <c>false</c> when an existing row was left untouched.
|
|
/// Commits internally — this call is its own transaction.
|
|
/// </summary>
|
|
/// <param name="n">The notification to insert.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if inserted, false if already exists.</returns>
|
|
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Returns notifications ready for a delivery attempt: <c>Pending</c> rows, plus
|
|
/// <c>Retrying</c> rows whose <c>NextAttemptAt</c> is at or before <paramref name="now"/>.
|
|
/// Terminal rows are excluded. Ordered by <c>CreatedAt</c> ascending, capped at
|
|
/// <paramref name="batchSize"/>.
|
|
/// </summary>
|
|
/// <param name="now">The current time for evaluating due retries.</param>
|
|
/// <param name="batchSize">Maximum number of rows to return.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A list of notifications ready for delivery.</returns>
|
|
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Returns the notification with the given id, or <c>null</c>.</summary>
|
|
/// <param name="notificationId">The notification identifier.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The notification, or null if not found.</returns>
|
|
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Persists <paramref name="n"/>'s <b>delivery-state</b> columns —
|
|
/// <c>Status</c>, <c>RetryCount</c>, <c>LastError</c>, <c>ResolvedTargets</c>,
|
|
/// <c>LastAttemptAt</c>, <c>NextAttemptAt</c>, <c>DeliveredAt</c>. Commits
|
|
/// internally — this call is its own transaction.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Scope is deliberately narrow.</b> Those seven columns are the ONLY
|
|
/// mutable state a notification has: everything else (identity, type, list,
|
|
/// subject, body, type data, source/origin attribution, enqueue and creation
|
|
/// timestamps) is written once at ingest and is immutable by contract. Every
|
|
/// caller — the dispatcher's per-attempt write and the operator retry/discard
|
|
/// one-shots — touches only this set. Implementations are therefore free to
|
|
/// issue a targeted UPDATE of these columns rather than rewriting the whole
|
|
/// row, which matters because the row carries <c>nvarchar(max)</c> body and
|
|
/// payload columns and the dispatcher writes on EVERY attempt. A future
|
|
/// caller that needs to change an immutable column must add its own method
|
|
/// rather than widening this one.
|
|
/// <para>
|
|
/// <b>The return value is the not-found signal.</b> A targeted UPDATE against
|
|
/// a row that no longer exists (the retention purge removed it between the
|
|
/// read and the write) affects zero rows and is otherwise indistinguishable
|
|
/// from success — which is how an operator Retry/Discard came to report
|
|
/// success against a vanished notification. Implementations MUST return
|
|
/// <see langword="false"/> when no row matched. Callers that speak to a human
|
|
/// (the operator one-shots) must surface that as "not found"; the dispatcher
|
|
/// treats it as a lost race and logs.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="n">The notification whose delivery state should be persisted.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>
|
|
/// <see langword="true"/> when the row was found and its delivery state
|
|
/// persisted; <see langword="false"/> when no row with that
|
|
/// <c>NotificationId</c> exists any more.
|
|
/// </returns>
|
|
Task<bool> UpdateAsync(Notification n, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Returns a page of notifications matching <paramref name="filter"/>, ordered by
|
|
/// <c>CreatedAt</c> descending, together with the total matching count.
|
|
/// </summary>
|
|
/// <param name="filter">The query filter.</param>
|
|
/// <param name="pageNumber">The page number (1-based).</param>
|
|
/// <param name="pageSize">The page size.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A tuple of rows and total count.</returns>
|
|
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
|
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Bulk-deletes terminal rows (Delivered/Parked/Discarded) whose <c>CreatedAt</c> is
|
|
/// older than <paramref name="cutoff"/>. Returns the number of rows deleted.
|
|
/// </summary>
|
|
/// <param name="cutoff">The cutoff time for deletion.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The number of rows deleted.</returns>
|
|
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Computes a point-in-time <see cref="NotificationKpiSnapshot"/>. The stuck and
|
|
/// delivered cutoffs are supplied by the caller; the current time used for
|
|
/// <c>OldestPendingAge</c> is captured inside the method.
|
|
/// </summary>
|
|
/// <param name="stuckCutoff">The time threshold for marking notifications as stuck.</param>
|
|
/// <param name="deliveredSince">The time threshold for counting delivered notifications.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A KPI snapshot.</returns>
|
|
Task<NotificationKpiSnapshot> ComputeKpisAsync(
|
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Computes a point-in-time <see cref="SiteNotificationKpiSnapshot"/> per source site.
|
|
/// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs
|
|
/// are supplied by the caller; the current time used for <c>OldestPendingAge</c> is
|
|
/// captured inside the method.
|
|
/// </summary>
|
|
/// <param name="stuckCutoff">The time threshold for marking notifications as stuck.</param>
|
|
/// <param name="deliveredSince">The time threshold for counting delivered notifications.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A list of per-site KPI snapshots.</returns>
|
|
Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Computes a point-in-time <see cref="NodeNotificationKpiSnapshot"/> per originating node.
|
|
/// Nodes with no notification rows at all are omitted; rows with a <c>NULL</c>
|
|
/// <c>SourceNode</c> are excluded. The stuck and delivered cutoffs are supplied by the
|
|
/// caller; the current time used for <c>OldestPendingAge</c> is captured inside the method.
|
|
/// </summary>
|
|
/// <param name="stuckCutoff">The time threshold for marking notifications as stuck.</param>
|
|
/// <param name="deliveredSince">The time threshold for counting delivered notifications.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A list of per-node KPI snapshots, ordered by node name.</returns>
|
|
Task<IReadOnlyList<NodeNotificationKpiSnapshot>> ComputePerNodeKpisAsync(
|
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Persists pending changes tracked on the underlying context. Use this when staging
|
|
/// multiple changes for a single commit; the individual mutating methods on this
|
|
/// interface already commit on their own.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The number of changes persisted.</returns>
|
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
|
}
|