1cea01d924
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).
192 lines
12 KiB
C#
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->(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);
|
|
}
|