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);
}