Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs
T
Joseph Doherty 1cea01d924 fix(notifications): list sort index + CAS operator Retry/Discard (P5-3, P5-4)
P5-3: add IX_Notifications_Created_NotificationId (CreatedAt DESC,
NotificationId DESC) — the Central UI Notification Outbox list page sorts on
exactly that pair, but no existing index leads unconditionally on CreatedAt,
so an unfiltered/lightly filtered page fell back to a full clustered-index
scan + sort on every COUNT + page request. Mirrors the Medium wave's
IX_SiteCalls_Created_TrackedOperationId migration + SQL twin pattern
faithfully, including the handwritten docs/plans/sql script.

P5-4: operator Retry/Discard was read-check-write keyed on NotificationId
only — a detached PipeTo task interleaving with the dispatcher's own write
(or a second concurrent operator action) could land between the read and
the write and get silently clobbered by the unconditional UpdateAsync,
stamping a Delivered row Discarded with stale fields. Adds
INotificationOutboxRepository.TryUpdateFromParkedAsync, a single-statement
compare-and-swap (WHERE Status == Parked) mirroring
SecuredWriteRepository.TryMarkApprovedAsync's CAS shape. NotificationOutboxActor's
RetryAsync/DiscardAsync now use the CAS write and, on a lost race, re-read
to report "notification already progressed to <Status>" instead of silently
overwriting whatever is there now.

Proof of failure: reverting only the actor's CAS usage while keeping the
repository addition reproduces the silent-overwrite bug — 7 tests fail,
including two new deterministic (non-sleep) race-proof tests that mock the
exact GetByIdAsync -> lost-CAS -> re-read sequence the finding describes.
P5-3 is perf-only; verified via migration/SQL-twin parity instead (no
runtime proof-of-failure is meaningful for an index).
2026-08-19 16:41:04 -04:00

192 lines
12 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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>
/// Compare-and-swap variant of <see cref="UpdateAsync"/> for the operator
/// Retry/Discard one-shots: persists <paramref name="n"/>'s delivery-state
/// columns (same narrow column set as <see cref="UpdateAsync"/>) ONLY if the
/// row's current <c>Status</c> in the database is still
/// <see cref="NotificationStatus.Parked"/>. Commits internally — this call
/// is its own transaction.
/// </summary>
/// <remarks>
/// <b>Why this exists (arch-review 2026-08-19 P5-4).</b> The operator retry/
/// discard handlers are read-check-write across two round trips:
/// <see cref="GetByIdAsync"/> reads the row, the handler checks its
/// in-memory <c>Status</c>, then a write persists the transition. A detached
/// <c>PipeTo</c> task can interleave with the dispatcher's own write (or a
/// second concurrent operator action) between those two round trips, so the
/// in-memory check is a fast-path rejection, not a correctness guard — a
/// blind <see cref="UpdateAsync"/> in that window can stamp a row that has
/// already moved on (e.g. the dispatcher delivered it) with the operator's
/// stale fields, silently overwriting a <c>Delivered</c> row as
/// <c>Discarded</c>. The <c>WHERE Status = Parked</c> predicate below makes
/// the <c>Parked-&gt;(Pending|Discarded)</c> transition atomic at the row
/// level, mirroring <c>SecuredWriteRepository.TryMarkApprovedAsync</c>'s
/// single-statement compare-and-swap. A row that has already progressed
/// (dispatcher won the race, or a second operator action won it) and a row
/// that has vanished (retention purge) both report zero rows affected;
/// callers cannot tell those apart from the return value alone and must
/// re-read to report which honestly.
/// </remarks>
/// <param name="n">The notification whose delivery state should be persisted, if still Parked.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// <see langword="true"/> when a row with that <c>NotificationId</c> was
/// found with <c>Status == Parked</c> and its delivery state was persisted;
/// <see langword="false"/> otherwise (not found, or no longer Parked).
/// </returns>
Task<bool> TryUpdateFromParkedAsync(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);
}