364 lines
20 KiB
C#
364 lines
20 KiB
C#
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
|
|
/// <summary>
|
|
/// Append-only data access for the central <c>AuditLog</c> table.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The append-only invariant is enforced both at the SQL level (the
|
|
/// <c>scadabridge_audit_writer</c> role has only INSERT + SELECT — UPDATE and DELETE
|
|
/// are not granted) and at the API level: this interface deliberately exposes no
|
|
/// Update and no single-row Delete. Bulk purge is performed exclusively via
|
|
/// monthly partition switch-out (<see cref="SwitchOutPartitionAsync"/>).
|
|
/// </para>
|
|
/// <para>
|
|
/// Ingest is idempotent on <c>EventId</c>: <see cref="InsertIfNotExistsAsync"/> is
|
|
/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
|
|
/// the same writer without producing duplicates.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface IAuditLogRepository
|
|
{
|
|
/// <summary>
|
|
/// Inserts <paramref name="evt"/> if no row with the same
|
|
/// <see cref="AuditEvent.EventId"/> exists; otherwise silently leaves the
|
|
/// stored row untouched (first-write-wins). Bypasses the EF change tracker
|
|
/// so the row never enters a tracked state.
|
|
/// </summary>
|
|
/// <param name="evt">The audit event to insert.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <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>.
|
|
/// Use keyset paging by passing the last returned row's
|
|
/// <c>OccurredAtUtc</c> + <c>EventId</c> back via
|
|
/// <see cref="AuditLogPaging.AfterOccurredAtUtc"/> +
|
|
/// <see cref="AuditLogPaging.AfterEventId"/> to fetch the next page.
|
|
/// </summary>
|
|
/// <param name="filter">Filter criteria to apply to the query.</param>
|
|
/// <param name="paging">Paging cursor and page size.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the matching audit events for the requested page, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.</returns>
|
|
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
|
AuditLogQueryFilter filter,
|
|
AuditLogPaging paging,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Switches out (purges) the monthly partition whose lower bound is
|
|
/// <paramref name="monthBoundary"/> and returns the approximate number
|
|
/// of rows discarded — sampled inside the transaction BEFORE the switch
|
|
/// so the row count reflects what the switch removed, not a post-purge
|
|
/// scan of a table that no longer exists.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <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>
|
|
/// 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
|
|
/// 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-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>
|
|
Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Per-channel retention override purge. Deletes <c>AuditLog</c> rows for a
|
|
/// single <paramref name="channel"/> (matched against the canonical
|
|
/// <c>Category</c> column — the bare channel name, e.g. <c>ApiOutbound</c>) whose
|
|
/// <c>OccurredAtUtc</c> is strictly older than <paramref name="threshold"/>, in
|
|
/// bounded batches of <paramref name="batchSize"/> rows, looping until no further
|
|
/// rows match. Returns the total number of rows deleted across all batches.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Maintenance path — NOT the writer role.</b> The append-only invariant binds
|
|
/// the <c>scadabridge_audit_writer</c> ingest role (INSERT + SELECT only). This row
|
|
/// DELETE runs on the purge/maintenance connection, the same path that performs the
|
|
/// global partition switch-out (also a destructive operation forbidden to the writer
|
|
/// role). Per-channel overrides can only ever expire rows EARLIER than the global
|
|
/// month-partition switch-out would — never later — so this is a strict tightening
|
|
/// of the retention window, applied AFTER the global purge on the same tick.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Bounded + idempotent.</b> Each batch is a <c>DELETE TOP (@batch)</c> so the
|
|
/// transaction log and lock footprint stay bounded regardless of backlog. Re-running
|
|
/// the purge is a no-op once every eligible row is gone (the loop exits when a batch
|
|
/// deletes zero rows), so a crash mid-loop is recoverable by simply running again.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="channel">Canonical channel name (the <c>Category</c> column value, e.g. <c>ApiOutbound</c>).</param>
|
|
/// <param name="threshold">Rows with <c>OccurredAtUtc</c> strictly older than this UTC datetime are deleted.</param>
|
|
/// <param name="batchSize">Maximum rows deleted per batch; must be > 0.</param>
|
|
/// <param name="commandTimeout">
|
|
/// Optional per-batch <c>DELETE TOP</c> command timeout. When null the provider default applies.
|
|
/// The purge actor passes the same
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>
|
|
/// it uses for the partition switch so a large single batch under lock contention is not aborted
|
|
/// by the ~30s ADO.NET default (arch-review 04, S2).
|
|
/// </param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the total number of rows deleted across all batches.</returns>
|
|
Task<long> PurgeChannelOlderThanAsync(
|
|
string channel,
|
|
DateTime threshold,
|
|
int batchSize,
|
|
TimeSpan? commandTimeout = null,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
|
|
/// boundaries whose partitions contain only rows with
|
|
/// <see cref="AuditEvent.OccurredAtUtc"/> strictly older than
|
|
/// <paramref name="threshold"/>. Boundaries whose partition is empty are
|
|
/// excluded (a no-op switch is wasted work). Used by the purge actor
|
|
/// to enumerate retention-eligible months on every tick.
|
|
/// </summary>
|
|
/// <param name="threshold">Only partitions whose data is entirely older than this UTC datetime are returned.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the list of partition lower-bound boundaries eligible for purge.</returns>
|
|
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
|
DateTime threshold,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Returns aggregate counts over the
|
|
/// trailing <paramref name="window"/> driving the central Health
|
|
/// dashboard's Audit KPI tiles.
|
|
/// </summary>
|
|
/// <param name="window">
|
|
/// Trailing time window (e.g. <c>TimeSpan.FromHours(1)</c>). Rows whose
|
|
/// <c>OccurredAtUtc >= nowUtc - window</c> are counted; the upper
|
|
/// bound is <paramref name="nowUtc"/>.
|
|
/// </param>
|
|
/// <param name="nowUtc">
|
|
/// Optional explicit "now" timestamp used to anchor the trailing window.
|
|
/// Defaults to <see cref="DateTime.UtcNow"/> at call time when null —
|
|
/// production callers should leave this null; tests pin a deterministic
|
|
/// value so the window is reproducible across runs.
|
|
/// </param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>
|
|
/// A snapshot with <c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c>
|
|
/// populated; <c>BacklogTotal</c> is left at zero (this method has no
|
|
/// visibility into per-site backlogs — the service layer composes it in
|
|
/// from <see cref="ZB.MOM.WW.ScadaBridge.HealthMonitoring.ICentralHealthAggregator"/>).
|
|
/// <c>AsOfUtc</c> is set to the server-side <c>UtcNow</c> at the time of
|
|
/// the query.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Implemented as a single aggregate query
|
|
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors</c>) rather than
|
|
/// two round trips so the volume + error rate tiles read a consistent
|
|
/// snapshot — the denominator and numerator come from the same scan.
|
|
/// </para>
|
|
/// <para>
|
|
/// Errors are defined as <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Failed"/>,
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Parked"/>, or
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus.Discarded"/>
|
|
/// — every non-success terminal lifecycle state. <c>Submitted</c>,
|
|
/// <c>Forwarded</c>, <c>Attempted</c> are in-flight and are NOT errors;
|
|
/// <c>Delivered</c> is success; <c>Skipped</c> is an intentional no-op.
|
|
/// </para>
|
|
/// </remarks>
|
|
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
|
TimeSpan window,
|
|
DateTime? nowUtc = null,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Audit Log ParentExecutionId feature — given any
|
|
/// <paramref name="executionId"/> in an execution chain, returns the whole
|
|
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
|
|
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
|
|
/// UI renders the result as a tree.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The input id may be any node in the chain — a leaf, the root, or a middle
|
|
/// node. The implementation first walks <em>up</em> via
|
|
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
|
|
/// the root via a recursive CTE, so the full chain is returned regardless of
|
|
/// entry point.
|
|
/// </para>
|
|
/// <para>
|
|
/// The <c>ParentExecutionId</c> graph is a tree (acyclic by construction —
|
|
/// each execution is minted fresh and its parent always pre-exists). Both
|
|
/// the upward walk and the downward CTE are nonetheless bounded at 32 levels
|
|
/// as a guard against corrupt/pathological data: a depth that exceeds the
|
|
/// guard raises an error rather than hanging the server. Chains are shallow
|
|
/// (1-2 levels typical) so the guard is never reached in practice.
|
|
/// </para>
|
|
/// <para>
|
|
/// A "stub" node — an execution that emitted no rows of its own yet is
|
|
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
|
|
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
|
|
/// = 0. A purged/missing parent simply ends the upward walk.
|
|
/// </para>
|
|
/// <para>
|
|
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
|
|
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
|
|
/// single stub node for <paramref name="executionId"/> itself
|
|
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
|
|
/// stub-node treatment of any other row-less execution.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution.</returns>
|
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
|
Guid executionId,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
|
|
/// <c>AuditLog</c> table, in ascending order. Backs the Audit Log page's
|
|
/// "Node" multi-select filter dropdown — the Central UI caches the result
|
|
/// for ~60s so the repository is hit at most once per minute per circuit.
|
|
/// </summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the distinct, non-null source node names in ascending order.</returns>
|
|
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// One-time operational backfill: sets <c>SourceNode</c> to
|
|
/// <paramref name="sentinel"/> on every row where <c>SourceNode IS NULL</c>
|
|
/// and <c>OccurredAtUtc < <paramref name="before"/></c>, in bounded
|
|
/// batches of <paramref name="batchSize"/> rows, looping until no further
|
|
/// rows match. Returns the total number of rows updated across all batches.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a sentinel, not the real value.</b> <c>SourceNode</c> captures the
|
|
/// physical cluster node on which an event was emitted. For pre-feature rows
|
|
/// that were ingested before the column was stamped, the true node-of-origin
|
|
/// is UNKNOWABLE — the original emitter is long gone and there is no
|
|
/// retroactive way to determine it. Backfilling a configurable sentinel
|
|
/// (default <c>"unknown"</c>) makes it explicit that these rows pre-date the
|
|
/// feature rather than silently leaving them NULL (which the filter UI already
|
|
/// treats as "unresolved" but which an operator might mistake for a bug).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b><c>ExecutionId</c> / <c>ParentExecutionId</c> cannot be backfilled.</b>
|
|
/// These are PERSISTED COMPUTED columns derived from <c>DetailsJson</c>. The
|
|
/// AuditLog append-only invariant forbids mutating <c>DetailsJson</c>, so
|
|
/// the computed values for pre-feature rows remain NULL permanently. This is
|
|
/// documented rather than coded — see the Ops Note in
|
|
/// <c>Component-AuditLog.md § Ops Notes — Historical Null Columns</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Maintenance path — NOT the writer role.</b> This UPDATE runs on the
|
|
/// purge/maintenance connection (the same path as
|
|
/// <see cref="SwitchOutPartitionAsync"/> and any per-channel purge), NOT the
|
|
/// append-only <c>scadabridge_audit_writer</c> role. The CI guard
|
|
/// (<c>AuditLogAppendOnlyGuardTests</c>) recognises the
|
|
/// <c>// AUDIT-PURGE-ALLOWED</c> marker on the UPDATE line and forgives
|
|
/// exactly this one sanctioned maintenance-path UPDATE; any other UPDATE
|
|
/// against <c>AuditLog</c> still trips the guard.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Bounded + idempotent.</b> <c>UPDATE TOP (@batch)</c> caps the
|
|
/// transaction-log and lock footprint per statement. The loop exits when a
|
|
/// batch updates zero rows, so a crash mid-loop is recoverable by simply
|
|
/// running again; re-running after completion is a no-op (no NULL rows
|
|
/// remain for the given <paramref name="before"/> window).
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="sentinel">Value to write into <c>SourceNode</c> for pre-feature rows (e.g. <c>"unknown"</c>).</param>
|
|
/// <param name="before">Rows with <c>OccurredAtUtc</c> strictly older than this UTC datetime are eligible.</param>
|
|
/// <param name="batchSize">Maximum rows updated per batch; must be > 0.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A task that resolves to the total number of rows updated across all batches.</returns>
|
|
Task<long> BackfillSourceNodeAsync(
|
|
string sentinel,
|
|
DateTime before,
|
|
int batchSize,
|
|
CancellationToken ct = default);
|
|
}
|