perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene
This commit is contained in:
@@ -34,6 +34,62 @@ public interface IAuditLogRepository
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Set-based form of <see cref="InsertIfNotExistsAsync"/>: inserts every
|
||||
/// event in <paramref name="events"/> that does not already exist, in as few
|
||||
/// round trips as the provider allows, and returns the number of rows
|
||||
/// actually written. First-write-wins idempotency is unchanged — an EventId
|
||||
/// already present is silently skipped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Duplicate EventIds are tolerated both within and across packets.</b>
|
||||
/// Duplicates inside one call collapse to the first occurrence before the
|
||||
/// statement is built (an append-only row is immutable, so later copies of an
|
||||
/// EventId carry the same content); duplicates against already-committed rows
|
||||
/// are eliminated by the anti-semi-join. A concurrent writer that commits a
|
||||
/// row mid-statement is handled by falling back to the per-row path, so the
|
||||
/// batch is a throughput optimisation and never a correctness dependency.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Default implementation.</b> The interface supplies a per-row loop so a
|
||||
/// test double or an alternative store keeps working unchanged; the EF Core
|
||||
/// implementation overrides it with a genuine set-based statement. Callers on
|
||||
/// the ingest hot path should always prefer this method — the per-row form
|
||||
/// cost one <c>IF NOT EXISTS … INSERT</c> round trip per audit event.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="events">Audit events to insert; may contain duplicates.</param>
|
||||
/// <param name="commandTimeout">
|
||||
/// Optional per-statement timeout. The ingest actor passes a budget strictly
|
||||
/// SHORTER than the gRPC Ask that wraps it, so the reply is produced before
|
||||
/// the caller gives up rather than at the same instant.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the number of rows inserted (duplicates excluded).</returns>
|
||||
async Task<int> InsertManyIfNotExistsAsync(
|
||||
IReadOnlyList<AuditEvent> events,
|
||||
TimeSpan? commandTimeout = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var inserted = 0;
|
||||
var seen = new HashSet<Guid>(events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (evt is null || !seen.Add(evt.EventId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await InsertIfNotExistsAsync(evt, ct).ConfigureAwait(false);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to <see cref="AuditLogPaging.PageSize"/> rows matching
|
||||
/// <paramref name="filter"/>, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.
|
||||
@@ -60,36 +116,32 @@ public interface IAuditLogRepository
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Drop-and-rebuild dance.</b> <c>UX_AuditLog_EventId</c> is intentionally
|
||||
/// non-partition-aligned (it lives on <c>[PRIMARY]</c> so single-column
|
||||
/// EventId uniqueness — required by <see cref="InsertIfNotExistsAsync"/> —
|
||||
/// can be enforced cheaply). SQL Server rejects
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> while a non-aligned unique index
|
||||
/// is present, so the implementation drops the index, creates a staging
|
||||
/// table with byte-identical schema, switches the partition's data into
|
||||
/// staging, drops staging (discarding the rows), and rebuilds the unique
|
||||
/// index. The CATCH branch guarantees the index is rebuilt even on partial
|
||||
/// failure so the table never returns to live traffic without its
|
||||
/// idempotency-supporting index.
|
||||
/// <b>Partition-aligned uniqueness — no index drop.</b> EventId uniqueness is
|
||||
/// enforced by the clustered <c>PK_AuditLog (EventId, OccurredAtUtc)</c>,
|
||||
/// which is aligned on <c>ps_AuditLog_Month(OccurredAtUtc)</c>, so
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> has no non-aligned unique index to
|
||||
/// object to. The implementation creates a staging table with byte-identical
|
||||
/// schema, switches the partition's data into staging, and drops staging
|
||||
/// (discarding the rows). Nothing is dropped from the live table, so there is
|
||||
/// no window in which the idempotency enforcement is absent and no offline
|
||||
/// index rebuild inside the switch transaction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Outage window.</b> The dance briefly removes the unique index, so
|
||||
/// concurrent <see cref="InsertIfNotExistsAsync"/> calls during the switch
|
||||
/// could in principle race past the IF NOT EXISTS check without the index
|
||||
/// catching the duplicate. This is acceptable for the daily purge cadence
|
||||
/// — the inserts that the IF NOT EXISTS check guards are themselves rare
|
||||
/// enough that a sub-second collision window is operationally negligible,
|
||||
/// and the composite PK still rejects same-(EventId, OccurredAtUtc) rows.
|
||||
/// The predecessor design carried a non-aligned
|
||||
/// <c>UX_AuditLog_EventId</c> on <c>[PRIMARY]</c> that had to be dropped and
|
||||
/// rebuilt around every switch. A defensive guarded <c>DROP INDEX</c> remains
|
||||
/// in the batch so a database restored from a pre-alignment backup still
|
||||
/// purges; it is a one-way cleanup, never rebuilt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="monthBoundary">Lower-bound datetime of the monthly partition to switch out.</param>
|
||||
/// <param name="commandTimeout">
|
||||
/// Optional per-command timeout for the maintenance SQL (the row-count sample plus the
|
||||
/// drop-and-rebuild dance). When null the provider default applies. The purge actor passes
|
||||
/// staging/switch batch). When null the provider default applies. The purge actor passes
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>
|
||||
/// (default 30 min) because the ~30s ADO.NET default can abort the switch mid-dance on a large
|
||||
/// or contended partition, leaving the table without <c>UX_AuditLog_EventId</c> until the next
|
||||
/// tick recovers (arch-review 04, S2).
|
||||
/// (default 30 min) because the ~30s ADO.NET default can abort the switch mid-batch on a large
|
||||
/// or contended partition, leaving an orphaned staging table for the next tick's CATCH to clean
|
||||
/// up (arch-review 04, S2).
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the approximate number of rows discarded by the partition switch.</returns>
|
||||
|
||||
+18
-3
@@ -46,10 +46,25 @@ public interface INotificationOutboxRepository
|
||||
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks <paramref name="n"/> modified and persists it (status transitions).
|
||||
/// Commits internally — this call is its own transaction.
|
||||
/// 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>
|
||||
/// <param name="n">The notification to update.</param>
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
/// <param name="n">The notification whose delivery state should be persisted.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the notification has been persisted.</returns>
|
||||
Task UpdateAsync(Notification n, CancellationToken cancellationToken = default);
|
||||
|
||||
Reference in New Issue
Block a user