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; /// /// Append-only data access for the central AuditLog table. /// /// /// /// The append-only invariant is enforced both at the SQL level (the /// scadabridge_audit_writer 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 (). /// /// /// Ingest is idempotent on EventId: is /// first-write-wins, so retrying telemetry and reconciliation pulls can both feed /// the same writer without producing duplicates. /// /// public interface IAuditLogRepository { /// /// Inserts if no row with the same /// exists; otherwise silently leaves the /// stored row untouched (first-write-wins). Bypasses the EF change tracker /// so the row never enters a tracked state. /// /// The audit event to insert. /// Cancellation token. /// A task that represents the asynchronous operation. Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default); /// /// Set-based form of : inserts every /// event in 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. /// /// /// /// Duplicate EventIds are tolerated both within and across packets. /// 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. /// /// /// Default implementation. 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 IF NOT EXISTS … INSERT round trip per audit event. /// /// /// Audit events to insert; may contain duplicates. /// /// 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. /// /// Cancellation token. /// A task that resolves to the number of rows inserted (duplicates excluded). async Task InsertManyIfNotExistsAsync( IReadOnlyList events, TimeSpan? commandTimeout = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(events); var inserted = 0; var seen = new HashSet(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; } /// /// Returns up to rows matching /// , ordered by (OccurredAtUtc DESC, EventId DESC). /// Use keyset paging by passing the last returned row's /// OccurredAtUtc + EventId back via /// + /// to fetch the next page. /// /// Filter criteria to apply to the query. /// Paging cursor and page size. /// Cancellation token. /// A task that resolves to the matching audit events for the requested page, ordered by (OccurredAtUtc DESC, EventId DESC). Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default); /// /// Switches out (purges) the monthly partition whose lower bound is /// 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. /// /// /// /// Partition-aligned uniqueness — no index drop. EventId uniqueness is /// enforced by the clustered PK_AuditLog (EventId, OccurredAtUtc), /// which is aligned on ps_AuditLog_Month(OccurredAtUtc), so /// ALTER TABLE … SWITCH PARTITION 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. /// /// /// The predecessor design carried a non-aligned /// UX_AuditLog_EventId on [PRIMARY] that had to be dropped and /// rebuilt around every switch. A defensive guarded DROP INDEX remains /// in the batch so a database restored from a pre-alignment backup still /// purges; it is a one-way cleanup, never rebuilt. /// /// /// Lower-bound datetime of the monthly partition to switch out. /// /// 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 /// /// (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). /// /// Cancellation token. /// A task that resolves to the approximate number of rows discarded by the partition switch. Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default); /// /// Per-channel retention override purge. Deletes AuditLog rows for a /// single (matched against the canonical /// Category column — the bare channel name, e.g. ApiOutbound) whose /// OccurredAtUtc is strictly older than , in /// bounded batches of rows, looping until no further /// rows match. Returns the total number of rows deleted across all batches. /// /// /// /// Maintenance path — NOT the writer role. The append-only invariant binds /// the scadabridge_audit_writer 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. /// /// /// Bounded + idempotent. Each batch is a DELETE TOP (@batch) 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. /// /// /// Canonical channel name (the Category column value, e.g. ApiOutbound). /// Rows with OccurredAtUtc strictly older than this UTC datetime are deleted. /// Maximum rows deleted per batch; must be > 0. /// /// Optional per-batch DELETE TOP command timeout. When null the provider default applies. /// The purge actor passes the same /// /// 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). /// /// Cancellation token. /// A task that resolves to the total number of rows deleted across all batches. Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default); /// /// Returns the set of pf_AuditLog_Month partition lower-bound /// boundaries whose partitions contain only rows with /// strictly older than /// . 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. /// /// Only partitions whose data is entirely older than this UTC datetime are returned. /// Cancellation token. /// A task that resolves to the list of partition lower-bound boundaries eligible for purge. Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default); /// /// Returns aggregate counts over the /// trailing driving the central Health /// dashboard's Audit KPI tiles. /// /// /// Trailing time window (e.g. TimeSpan.FromHours(1)). Rows whose /// OccurredAtUtc >= nowUtc - window are counted; the upper /// bound is . /// /// /// Optional explicit "now" timestamp used to anchor the trailing window. /// Defaults to at call time when null — /// production callers should leave this null; tests pin a deterministic /// value so the window is reproducible across runs. /// /// Cancellation token. /// /// A snapshot with TotalEventsLastHour + ErrorEventsLastHour /// populated; BacklogTotal is left at zero (this method has no /// visibility into per-site backlogs — the service layer composes it in /// from ). /// AsOfUtc is set to the server-side UtcNow at the time of /// the query. /// /// /// /// Implemented as a single aggregate query /// (SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors) rather than /// two round trips so the volume + error rate tiles read a consistent /// snapshot — the denominator and numerator come from the same scan. /// /// /// Errors are defined as , /// , or /// /// — every non-success terminal lifecycle state. Submitted, /// Forwarded, Attempted are in-flight and are NOT errors; /// Delivered is success; Skipped is an intentional no-op. /// /// Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default); /// /// Audit Log ParentExecutionId feature — given any /// in an execution chain, returns the whole /// chain rooted at the topmost ancestor: one /// per distinct execution, summarising its AuditLog rows. The Central /// UI renders the result as a tree. /// /// /// /// The input id may be any node in the chain — a leaf, the root, or a middle /// node. The implementation first walks up via /// ParentExecutionId to find the root, then walks down from /// the root via a recursive CTE, so the full chain is returned regardless of /// entry point. /// /// /// The ParentExecutionId 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. /// /// /// A "stub" node — an execution that emitted no rows of its own yet is /// referenced by a child via ParentExecutionId, or whose rows have /// been purged — still appears, with /// = 0. A purged/missing parent simply ends the upward walk. /// /// /// When no AuditLog row carries in /// either ExecutionId or ParentExecutionId, the result is a /// single stub node for itself /// ( = 0) — consistent with the /// stub-node treatment of any other row-less execution. /// /// /// Any execution id in the chain; the implementation walks to the root and back down. /// Cancellation token. /// A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution. Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default); /// /// Returns the distinct, non-null SourceNode values present in the /// AuditLog 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. /// /// Cancellation token. /// A task that resolves to the distinct, non-null source node names in ascending order. Task> GetDistinctSourceNodesAsync(CancellationToken ct = default); /// /// One-time operational backfill: sets SourceNode to /// on every row where SourceNode IS NULL /// and OccurredAtUtc < , in bounded /// batches of rows, looping until no further /// rows match. Returns the total number of rows updated across all batches. /// /// /// /// Why a sentinel, not the real value. SourceNode 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 "unknown") 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). /// /// /// ExecutionId / ParentExecutionId cannot be backfilled. /// These are PERSISTED COMPUTED columns derived from DetailsJson. The /// AuditLog append-only invariant forbids mutating DetailsJson, so /// the computed values for pre-feature rows remain NULL permanently. This is /// documented rather than coded — see the Ops Note in /// Component-AuditLog.md § Ops Notes — Historical Null Columns. /// /// /// Maintenance path — NOT the writer role. This UPDATE runs on the /// purge/maintenance connection (the same path as /// and any per-channel purge), NOT the /// append-only scadabridge_audit_writer role. The CI guard /// (AuditLogAppendOnlyGuardTests) recognises the /// // AUDIT-PURGE-ALLOWED marker on the UPDATE line and forgives /// exactly this one sanctioned maintenance-path UPDATE; any other UPDATE /// against AuditLog still trips the guard. /// /// /// Bounded + idempotent. UPDATE TOP (@batch) 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 window). /// /// /// Value to write into SourceNode for pre-feature rows (e.g. "unknown"). /// Rows with OccurredAtUtc strictly older than this UTC datetime are eligible. /// Maximum rows updated per batch; must be > 0. /// Cancellation token. /// A task that resolves to the total number of rows updated across all batches. Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default); }