From 5db2a810c095067038e048366c964030d043e901 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 14 Aug 2026 21:07:12 -0400 Subject: [PATCH] perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene --- docs/components/ConfigurationDatabase.md | 16 +- .../sql/AlignAuditLogEventIdUniqueness.sql | 24 + docs/requirements/Component-AuditLog.md | 36 +- .../Component-ConfigurationDatabase.md | 6 +- docs/requirements/Component-SiteCallAudit.md | 22 + .../Central/AuditLogIngestActor.cs | 230 +- .../Central/AuditLogPurgeActor.cs | 14 +- .../Central/AuditLogPurgeOptions.cs | 19 +- .../Repositories/IAuditLogRepository.cs | 96 +- .../INotificationOutboxRepository.cs | 21 +- .../AuditLogEntityTypeConfiguration.cs | 17 +- ...AlignAuditLogEventIdUniqueness.Designer.cs | 2090 +++++++++++++++++ ...15004957_AlignAuditLogEventIdUniqueness.cs | 93 + .../ScadaBridgeDbContextModelSnapshot.cs | 4 - .../Repositories/AuditLogRepository.cs | 269 ++- .../Repositories/KpiHistoryRepository.cs | 48 +- .../NotificationOutboxRepository.cs | 81 +- .../Repositories/SiteCallAuditRepository.cs | 113 +- .../ServiceCollectionExtensions.cs | 16 + .../SiteCallAuditActor.cs | 118 +- .../Central/AuditLogIngestActorTests.cs | 99 + .../Integration/PartitionPurgeTests.cs | 87 +- .../AuditLogEntityTypeConfigurationTests.cs | 34 +- .../AddAuditLogTableMigrationTests.cs | 30 +- ...ationOutboxRepositoryKpiQueryShapeTests.cs | 10 + .../Repositories/AuditLogRepositoryTests.cs | 216 +- .../Repositories/KpiHistoryRepositoryTests.cs | 87 + .../SiteCallAuditRepositoryTests.cs | 41 + .../SiteCallAuditReconciliationTests.cs | 119 + 29 files changed, 3790 insertions(+), 266 deletions(-) create mode 100644 docs/plans/sql/AlignAuditLogEventIdUniqueness.sql create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.cs diff --git a/docs/components/ConfigurationDatabase.md b/docs/components/ConfigurationDatabase.md index f98a9661..5ca6a6c7 100644 --- a/docs/components/ConfigurationDatabase.md +++ b/docs/components/ConfigurationDatabase.md @@ -78,13 +78,15 @@ builder.Property(e => e.ExecutionId) "CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true) .ValueGeneratedOnAddOrUpdate(); -// Composite PK includes OccurredAtUtc for partition alignment +// Composite PK includes OccurredAtUtc for partition alignment — and is the ONLY +// EventId uniqueness enforcement (WP2.2 / AlignAuditLogEventIdUniqueness). EventId +// is a GUID minted once at the site alongside OccurredAtUtc, so a given EventId +// always lands in one partition and pair-uniqueness is EventId-uniqueness. builder.HasKey(e => new { e.EventId, e.OccurredAtUtc }); - -builder.HasIndex(e => e.EventId).IsUnique() - .HasDatabaseName("UX_AuditLog_EventId"); ``` +The predecessor `UX_AuditLog_EventId` — a single-column unique index on `[PRIMARY]`, deliberately NOT partition-aligned — was dropped: a non-aligned index blocks `ALTER TABLE … SWITCH PARTITION`, so the retention purge had to drop it, switch, and rebuild it offline inside the switch transaction on every run. + **`TemplateConfiguration`** (representative of the domain-area configs) sets up the self-referencing parent FK, folder FK, cascade-delete relationships to attributes/alarms/scripts/compositions/native alarm sources, and the filtered unique index that enforces name uniqueness only on non-derived (base) templates. **`SiteCallEntityTypeConfiguration`** maps `SiteCall` to `dbo.SiteCalls` with a `TrackedOperationId` PK stored as `varchar(36)` (GUID in `"D"` format) so the column shape matches the wire format and the site SQLite store — one consistent format for operational debugging. @@ -108,11 +110,13 @@ VALUES `FormattableString` interpolation parameterises every value so there is no injection surface. SQL error numbers `2601` and `2627` (unique-index violation) are swallowed as no-ops because the IF NOT EXISTS check has a race window; both the check-loser and the retrying telemetry path are semantically correct duplicates. +`InsertManyIfNotExistsAsync` is the set-based form the ingest hot path uses: one `INSERT … SELECT … FROM (VALUES …) WHERE NOT EXISTS` per chunk of 100 rows (ten bound parameters per row against SQL Server's 2,100-parameter ceiling), so a telemetry packet costs one round trip instead of one per event. Because the anti-semi-join only sees committed rows, the packet is de-duplicated by `EventId` in C# first (first-write-wins, matching the single-row contract); a duplicate-key fault from a concurrent writer falls back to the per-row path so the batch is never a correctness dependency. It uses raw ADO.NET with explicitly typed `SqlParameter`s — the `VALUES` constructor derives its column types from the first row's parameters, so an untyped null would give the derived column the wrong type — and enlists in the DbContext's ambient transaction when one is open (the cached-telemetry dual-write). + `QueryAsync` builds LINQ predicates over `AuditLogRow` using `AsNoTracking()`, translating filter dimensions (`Channels`, `Kinds`, `Statuses`, `SourceSiteIds`, `SourceNodes`, `ExecutionId`, `ParentExecutionId`, time range) to server-side SQL IN/equality predicates and using keyset pagination on `(OccurredAtUtc DESC, EventId DESC)`. `GetExecutionTreeAsync` walks the `ParentExecutionId` graph in two phases: a loop climbs to the root (bounded at 32 levels), then a recursive CTE descends the full tree and LEFT JOINs back to `AuditLog` so stub nodes (purged or row-less executions) still appear with `RowCount = 0`. -`SwitchOutPartitionAsync` executes a drop-and-rebuild dance — dropping `UX_AuditLog_EventId`, creating a byte-identical staging table (including the computed-column definitions), switching the target partition to staging, dropping staging, and rebuilding the unique index — all inside a single `BEGIN TRY / BEGIN CATCH` block that guarantees the index is present whether the switch succeeds or rolls back. +`SwitchOutPartitionAsync` creates a byte-identical staging table (including the computed-column definitions), switches the target partition to staging, and drops staging, inside a single `BEGIN TRY / BEGIN CATCH` block whose CATCH cleans up the staging table on any failure. No index is dropped or rebuilt: uniqueness rides the partition-aligned clustered PK, so the switch is metadata-only. A guarded defensive `DROP INDEX UX_AuditLog_EventId` remains at the head of the batch purely so a database restored from a pre-alignment backup still purges. ### IAuditService — config-change audit @@ -241,7 +245,7 @@ The host is running in production mode and `GetPendingMigrationsAsync` found una ### AuditLog partition switch fails mid-operation -`SwitchOutPartitionAsync` wraps the drop-and-rebuild dance in `BEGIN TRY / BEGIN CATCH`. On failure the CATCH block drops the staging table if it exists and rebuilds `UX_AuditLog_EventId` if it was dropped before the failure. The original exception is re-thrown so the Audit Log purge actor logs it and retries on the next daily tick. Verify that the `scadabridge_audit_purger` role still holds `ALTER ON SCHEMA::dbo` if the operation fails with a permissions error. +`SwitchOutPartitionAsync` wraps the staging/switch batch in `BEGIN TRY / BEGIN CATCH`. On failure the CATCH block drops the staging table if it exists, so no orphaned `AuditLog_Staging_*` object is left behind. There is no index to repair — uniqueness lives on the clustered PK, which the switch never touches. The original exception is re-thrown so the Audit Log purge actor logs it and retries on the next daily tick. Verify that the `scadabridge_audit_purger` role still holds `ALTER ON SCHEMA::dbo` if the operation fails with a permissions error. ### Design-time `dotnet ef` tooling cannot find a connection string diff --git a/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql b/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql new file mode 100644 index 00000000..90b3003f --- /dev/null +++ b/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql @@ -0,0 +1,24 @@ +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260815004957_AlignAuditLogEventIdUniqueness' +) +BEGIN + + IF EXISTS (SELECT 1 FROM sys.indexes + WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260815004957_AlignAuditLogEventIdUniqueness' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260815004957_AlignAuditLogEventIdUniqueness', N'10.0.7'); +END; + +COMMIT; +GO + diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index c36ef201..67429493 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -291,6 +291,27 @@ stay `Pending` for the next sweep. Cadence is short (default 5 s) when non-empty, longer (default 30 s) when idle; telemetry runs on a dedicated dispatcher. +**Central-side ingest is set-based.** `AuditLogIngestActor` writes a whole +packet with ONE `InsertManyIfNotExistsAsync` statement rather than one +`IF NOT EXISTS … INSERT` round trip per event; the cached-telemetry dual-write +similarly runs the whole packet in ONE transaction (set-based audit insert plus +one single-statement `SiteCalls` upsert per entry) instead of a transaction per +entry. Idempotency is unchanged: duplicates that repeat *within* a packet +collapse first-write-wins before the statement is built, duplicates *across* +packets are eliminated by the anti-semi-join, and any failure falls back to the +per-row / per-entry path — so the documented invariant that one bad row cannot +sink the rest of the batch still holds, it is simply no longer paid for on the +healthy path. + +**Timeout ladder.** The ingest budget is deliberately the smallest on the path: +the site's Ask and the central gRPC handler's Ask are both 30 s, the actor's +own database budget is 20 s and the per-statement SQL timeout is 15 s. Before +this the three were identical, so they expired at the same instant and the +caller learned nothing but "it took 30 s" — no partial ack, no way to tell a +slow database from a wedged singleton. With the inner budgets strictly smaller, +a slow batch is abandoned by the actor first and the accepted-so-far ids are +still replied while the outer Asks are still waiting. + ### Reconciliation pull (self-healing for missed telemetry) A central `SiteAuditReconciliationActor` periodically (default 5 min per site) @@ -490,12 +511,19 @@ MS SQL for direct-write events). Unredacted secrets never persist. silently failing retention job would otherwise be invisible until the table grew unbounded. Per-boundary/per-channel error isolation is unchanged — a single failure still never abandons the rest of the tick. -- **Maintenance command timeout:** the switch-out drop-and-rebuild dance and each +- **Maintenance command timeout:** the switch-out staging batch and each per-channel `DELETE TOP` batch run with an explicit command timeout (`AuditLog:Purge:MaintenanceCommandTimeoutMinutes`, default 30, floor 1 min) rather - than the ~30 s ADO.NET default, which could abort the metadata-only SWITCH mid-dance - on a large or contended partition and leave the live table without - `UX_AuditLog_EventId` until a later tick's CATCH branch rebuilt it. + than the ~30 s ADO.NET default, which could abort the metadata-only SWITCH mid-batch + on a large or contended partition and leave an orphaned staging table for the next + tick's CATCH branch to clean up. +- **Partition-aligned uniqueness:** the switch no longer drops and rebuilds an index. + `EventId` uniqueness rides the clustered `PK_AuditLog (EventId, OccurredAtUtc)`, + which is aligned on `ps_AuditLog_Month(OccurredAtUtc)`, so `SWITCH PARTITION` has no + non-aligned unique index to object to. The predecessor `UX_AuditLog_EventId` forced + an offline whole-table index build inside the switch transaction — blocking every + audit writer for its duration — and left a window in which the idempotency-supporting + index did not exist at all. See migration `AlignAuditLogEventIdUniqueness`. - **Per-channel retention overrides (M5.5 T3):** `AuditLog:PerChannelRetentionDays` is a dictionary keyed by canonical channel name (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`, `SecuredWrite` — all five `AuditChannel` values are diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index e5bd42b0..4fd3e8d8 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -95,9 +95,11 @@ The configuration database stores all central system data, organized by domain a - `ParentExecutionId` — `CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)` PERSISTED — spawner's `ExecutionId`; null for top-level runs. - `IngestedAtUtc` — `CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))` — central ingest timestamp; **not** persisted (SQL Server rejects PERSISTED on the non-deterministic `SWITCHOFFSET` expression). - *Clustered primary key:* `(EventId, OccurredAtUtc)` — composite so the key is partition-aligned. `UX_AuditLog_EventId` (unique, non-aligned on `[PRIMARY]`) enforces global `EventId` uniqueness for `InsertIfNotExistsAsync` idempotency. + *Clustered primary key:* `(EventId, OccurredAtUtc)` — composite so the key is partition-aligned, and the **sole** `EventId` uniqueness enforcement backing `InsertIfNotExistsAsync` idempotency. `EventId` is a GUID minted once at the emitting site in the same operation that stamps `OccurredAtUtc`, and neither field is ever re-stamped downstream, so a given `EventId` always arrives with the same `OccurredAtUtc` and can only map to one partition — pair-uniqueness is `EventId`-uniqueness for every row the system produces. The idempotency probe (`WHERE EventId = @id`) still seeks the clustered key's leading column, at the cost of one seek per partition rather than one seek overall. - *Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)` except `UX_AuditLog_EventId`): + A non-aligned single-column `UX_AuditLog_EventId` on `[PRIMARY]` used to carry that uniqueness; it was dropped by the `AlignAuditLogEventIdUniqueness` migration because a non-aligned index blocks `ALTER TABLE … SWITCH PARTITION`, forcing the retention purge to drop it, switch, and rebuild it **offline inside the switch transaction** on every run. + + *Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)`): - `IX_AuditLog_OccurredAtUtc` (primary time-range index for global scans) - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` (per-site filters) - `IX_AuditLog_CorrelationId (CorrelationId) WHERE CorrelationId IS NOT NULL` (drilldown from a single operation) diff --git a/docs/requirements/Component-SiteCallAudit.md b/docs/requirements/Component-SiteCallAudit.md index 0b2c4f09..58b2388f 100644 --- a/docs/requirements/Component-SiteCallAudit.md +++ b/docs/requirements/Component-SiteCallAudit.md @@ -135,6 +135,28 @@ Pinned)` on the EventStream (transition-only, mirroring health-observable condition rather than a silent log line, and the latch clears with `Pinned=false` once a later tick makes progress. +**The drain runs off the mailbox.** A reconciliation pass is unbounded work — +every site, up to a page ceiling of network pulls each, one upsert per row — so +it runs as a background task with a `PipeTo`-delivered completion message and a +single-flight guard, not as an actor message handler. A handler occupies the +actor for its whole duration, so a post-outage catch-up used to park telemetry +ingest, UI queries and KPI Asks behind it; those callers time out rather than +queue, which made a slow site look like a dead central. The single-flight guard +is raised and lowered ON the actor thread, so the per-site cursor and pinned-latch +dictionaries the pass mutates are still only ever touched by one task at a time, +and the mailbox supplies the memory barrier between consecutive passes. The +daily terminal-row purge uses the same shape. (`NotificationOutboxActor`'s +dispatch sweep is the in-repo reference.) + +**The central upsert is one statement.** `SiteCallAuditRepository.UpsertAsync` +issues a single batch that runs the monotonic UPDATE first and INSERTs only when +nothing matched *and* the row genuinely does not exist — instead of an +unconditional insert-if-absent followed by the update, which cost two round trips +on every packet and wasted the insert half for every packet after the first. The +existence re-check is load-bearing: a zero row count also means "the monotonic +guard rejected this packet", and inserting there would fork the mirror with a +second row for an id that already exists. + ## Retry / Discard Relay Parked cached calls live in the owning site's S&F buffer. Operator Retry/Discard diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs index 8cece24d..e7b3f9a0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs @@ -18,8 +18,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// ingest timestamp into DetailsJson (there is no promoted IngestedAtUtc /// column — the value is a DetailsJson field set via /// ) and inserted idempotently -/// via — duplicates are -/// silently swallowed (first-write-wins). +/// via — duplicates +/// are silently swallowed (first-write-wins), whether they repeat inside one +/// packet or across packets. /// /// /// @@ -28,9 +29,11 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// consistent and the site is free to flip its local row to Forwarded. /// /// -/// Audit-write failures must NEVER abort the user-facing action. The actor -/// wraps each repository call in its own try/catch so a single bad row cannot -/// cause the rest of the batch to be lost, and it guards scope/repository +/// Audit-write failures must NEVER abort the user-facing action. Each message +/// is written as ONE set-based statement (or one transaction, for the cached +/// dual-write); if that fails the actor retries the same work row-by-row inside +/// per-row try/catch, so a single bad row still cannot cause the rest of the +/// batch to be lost. It also guards scope/repository /// resolution so a transient DI fault cannot restart the singleton — those /// catches are what keep this actor alive across handler throws, not the /// supervisor strategy. The override returns @@ -50,6 +53,31 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// public class AuditLogIngestActor : ReceiveActor { + /// + /// Overall budget for one ingest message's database work, deliberately + /// SHORTER than the gRPC Ask that wraps it. + /// + /// + /// The path used to stack three identical 30 s budgets — the site's Ask + /// (CommunicationOptions.NotificationForwardTimeout), the central gRPC + /// handler's Ask (SiteStreamGrpcServer.AuditIngestAskTimeout) and the + /// ADO.NET command default — so they all expired at the same instant. The + /// caller therefore learned nothing except "it took 30 s": no partial ack, no + /// distinction between a slow database and a wedged singleton. Making the + /// innermost budget strictly smallest means a slow batch is abandoned by the + /// actor FIRST, with the accepted-so-far ids still replied, while the outer + /// Asks are still waiting. + /// + internal static readonly TimeSpan IngestBudget = TimeSpan.FromSeconds(20); + + /// + /// Per-statement SQL timeout for the ingest write, strictly inside + /// so a single wedged statement surfaces as a SQL + /// timeout the per-batch catch can log, rather than consuming the whole + /// actor-level budget and starving the rest of the batch. + /// + internal static readonly TimeSpan IngestSqlCommandTimeout = TimeSpan.FromSeconds(15); + private readonly IServiceProvider? _serviceProvider; private readonly IAuditLogRepository? _injectedRepository; private readonly ILogger _logger; @@ -185,39 +213,75 @@ public class AuditLogIngestActor : ReceiveActor DateTime nowUtc, List accepted) { + // Stamp the ingest timestamp here, not at the site. Redact BEFORE the + // IngestedAtUtc stamp so the redacted copy carries the central-side + // ingest timestamp. The redactor is contract-bound to never throw; a null + // redactor (test composition root, no IAuditRedactor registered) falls + // back to the SafeDefault rather than pass-through, so HTTP header + // redaction always runs. IngestedAtUtc is a DetailsJson field on the + // canonical record, so stamp it via the projection helper. + var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance; + var projected = new List(cmd.Events.Count); foreach (var evt in cmd.Events) { - try + var filtered = safeRedactor.Apply(evt); + projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc)); + } + + // ONE set-based statement for the whole packet (WP2.2) instead of one + // IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200 + // rows used to cost 200 sequential round trips on the central singleton's + // dispatcher; it now costs two. Idempotency is unchanged — duplicates + // within the packet collapse first-write-wins, duplicates against + // committed rows are eliminated by the anti-semi-join, and a concurrent + // writer degrades to the repository's per-row fallback. + using var budget = new CancellationTokenSource(IngestBudget); + try + { + await repository + .InsertManyIfNotExistsAsync(projected, IngestSqlCommandTimeout, budget.Token) + .ConfigureAwait(false); + + // A batch that returned without throwing means every row is now + // present — inserted here or already committed by an earlier delivery. + // Both count as accepted: the storage state is consistent and the site + // is free to flip its local rows to Forwarded. + foreach (var evt in cmd.Events) { - // Stamp the ingest timestamp here, not at the site. The - // repository's duplicate-key hardening already swallows - // duplicate-key races, so the same id arriving twice (site - // retry, reconciliation) is a silent no-op. - // Redact BEFORE the IngestedAtUtc stamp so the redacted - // copy carries the central-side ingest timestamp. The redactor - // is contract-bound to never throw. A null - // redactor (test composition root, no IAuditRedactor - // registered) now falls back to the SafeDefault rather than - // pass-through, so HTTP header redaction always runs. - // IngestedAtUtc is a DetailsJson field on - // the canonical record, so stamp it via the projection helper. - var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance; - var filtered = safeRedactor.Apply(evt); - var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc); - await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); accepted.Add(evt.EventId); } - catch (Exception ex) + } + catch (Exception ex) + { + // The batch is a throughput optimisation, NOT a change to the + // failure grain. The documented invariant — "a single bad row cannot + // cause the rest of the batch to be lost" — is preserved by falling + // back to the per-row path here, so a poison row still costs only + // itself. Re-running rows the failed batch may already have committed + // is safe: every insert is idempotent on EventId. + _logger.LogWarning(ex, + "Set-based ingest of {Count} audit event(s) failed; falling back to per-row inserts so one bad row does not sink the batch.", + cmd.Events.Count); + + for (var i = 0; i < projected.Count; i++) { - // Per-row catch — one bad row never sinks the whole batch. - // The row stays Pending at the site; the next drain retries. - // Bump the central health counter so a - // sustained insert-throw failure surfaces on the dashboard. - try { failureCounter?.Increment(); } - catch { /* counter must never throw — defence in depth */ } - _logger.LogError(ex, - "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.", - evt.EventId); + try + { + await repository.InsertIfNotExistsAsync(projected[i], budget.Token).ConfigureAwait(false); + accepted.Add(cmd.Events[i].EventId); + } + catch (Exception rowEx) + { + // Per-row catch — one bad row never sinks the whole batch. + // The row stays Pending at the site; the next drain retries. + // Bump the central health counter so a sustained insert-throw + // failure surfaces on the dashboard. + try { failureCounter?.Increment(); } + catch { /* counter must never throw — defence in depth */ } + _logger.LogError(rowEx, + "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.", + cmd.Events[i].EventId); + } } } } @@ -262,6 +326,24 @@ public class AuditLogIngestActor : ReceiveActor var strategy = dbContext.Database.CreateExecutionStrategy(); + // Fast path (WP2.2): the WHOLE packet in ONE transaction — one + // set-based audit insert plus one single-statement upsert per entry. + // The predecessor opened a transaction PER entry and issued three + // statements inside it (IF NOT EXISTS insert, then the two-statement + // SiteCalls upsert), so a 50-entry packet cost ~200 round trips and 50 + // commits. If anything faults, the per-entry loop below re-runs the + // packet with its original per-entry isolation, so the documented + // "one entry's failure does not abort the others" invariant survives — + // it is simply no longer paid for on the healthy path. + using var budget = new CancellationTokenSource(IngestBudget); + if (await TryIngestCachedBatchAsync( + strategy, dbContext, auditRepo, siteCallRepo, redactor, cmd, accepted, budget.Token) + .ConfigureAwait(false)) + { + replyTo.Tell(new IngestCachedTelemetryReply(accepted)); + return; + } + foreach (var entry in cmd.Entries) { try @@ -330,6 +412,90 @@ public class AuditLogIngestActor : ReceiveActor replyTo.Tell(new IngestCachedTelemetryReply(accepted)); } + /// + /// Attempts the whole cached-telemetry packet as ONE transaction: a single + /// set-based audit insert followed by one monotonic SiteCalls upsert + /// per entry. Returns when it committed (and only then + /// appends to ), when the + /// caller should fall back to the per-entry transaction loop. + /// + /// + /// The batch is all-or-nothing by construction — it is a single transaction — + /// which is why a failure MUST fall back rather than report partial success: + /// the per-entry loop is what turns one poison entry into one lost entry + /// instead of a lost packet. Nothing is appended to + /// until the commit returns, so a failed attempt + /// leaves the caller's list untouched and the retry starts from a clean slate. + /// + private async Task TryIngestCachedBatchAsync( + Microsoft.EntityFrameworkCore.Storage.IExecutionStrategy strategy, + ScadaBridgeDbContext dbContext, + IAuditLogRepository auditRepo, + ISiteCallAuditRepository siteCallRepo, + IAuditRedactor? redactor, + IngestCachedTelemetryCommand cmd, + List accepted, + CancellationToken ct) + { + try + { + await strategy.ExecuteAsync(async () => + { + await using var tx = await dbContext.Database + .BeginTransactionAsync(ct) + .ConfigureAwait(false); + + // One central-side instant for the whole packet so a join across + // the two tables sees matching timestamps (debugging convenience, + // not a correctness invariant). + var ingestedAt = DateTime.UtcNow; + var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance; + + var auditRows = new List(cmd.Entries.Count); + foreach (var entry in cmd.Entries) + { + // Only the AuditLog row's payload columns are redactable; + // SiteCalls carries operational state only (status, retry + // count) and is left untouched. + var filteredAudit = safeRedactor.Apply(entry.Audit); + auditRows.Add(AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt)); + } + + await auditRepo + .InsertManyIfNotExistsAsync(auditRows, IngestSqlCommandTimeout, ct) + .ConfigureAwait(false); + + foreach (var entry in cmd.Entries) + { + await siteCallRepo + .UpsertAsync(entry.SiteCall with { IngestedAtUtc = ingestedAt }, ct) + .ConfigureAwait(false); + } + + await tx.CommitAsync(ct).ConfigureAwait(false); + }).ConfigureAwait(false); + } + catch (Exception ex) + { + // No health-counter bump here — the per-entry retry is the authority + // on whether this packet genuinely failed, and double-counting a + // batch that the fallback then writes successfully would make the + // dashboard read as a sustained fault during normal contention. + _logger.LogWarning( + ex, + "Batched cached-telemetry dual-write of {Count} entr(ies) failed; falling back to per-entry transactions.", + cmd.Entries.Count); + return false; + } + + foreach (var entry in cmd.Entries) + { + accepted.Add(entry.Audit.EventId); + } + + return true; + } + /// /// Fallback handler installed on the single-repository test ctor — that /// ctor has no DbContext and no , so diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs index dc95d731..d824c32d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs @@ -17,8 +17,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// for monthly boundaries whose latest OccurredAtUtc is older /// than DateTime.UtcNow - RetentionDays. /// For each eligible boundary, calls -/// which runs -/// the drop-and-rebuild dance around UX_AuditLog_EventId. +/// , a +/// metadata-only staging-table switch (WP2.2 removed the index +/// drop/rebuild that used to bracket it). /// Publishes on the actor-system /// EventStream so the central health collector + ops surfaces /// can subscribe without coupling to this actor. @@ -26,11 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// /// /// -/// Daily cadence. Partition switch is metadata-only but the -/// drop-and-rebuild dance briefly removes UX_AuditLog_EventId; running -/// more often than necessary trades unique-index rebuild outages for -/// negligible freshness wins. The default 24-hour interval matches -/// alog.md §10's retention policy. +/// Daily cadence. The partition switch is metadata-only, but it still +/// takes schema-modification locks on a table the ingest path is writing to; +/// running more often than necessary trades contention for negligible freshness +/// wins. The default 24-hour interval matches alog.md §10's retention policy. /// /// /// Continue-on-error. A single boundary that throws (transient SQL diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs index 5621cd56..0d97571c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs @@ -10,12 +10,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// /// /// -/// The purge actor is a daily-cadence singleton, not a hot-loop, because -/// partition-switch I/O is metadata-only but the drop-and-rebuild dance -/// briefly removes the UX_AuditLog_EventId unique index — running -/// more often than necessary trades index-rebuild outages for marginal -/// freshness gains. Lower this only when an operator can prove they need -/// sub-daily purge granularity. +/// The purge actor is a daily-cadence singleton, not a hot-loop. The +/// partition switch itself is metadata-only (since WP2.2's +/// AlignAuditLogEventIdUniqueness it no longer drops and rebuilds a +/// non-aligned unique index around it), but it still takes schema-modification +/// locks and competes with ingest for the same table. Lower this only when an +/// operator can prove they need sub-daily purge granularity. /// /// /// exists for tests to drop the cadence to @@ -58,15 +58,14 @@ public sealed class AuditLogPurgeOptions /// /// Per-command timeout (in minutes) for the maintenance SQL the purge tick issues — - /// both the partition switch-out drop-and-rebuild dance + /// both the partition switch-out staging batch /// () /// and each per-channel DELETE TOP batch. Default 30 minutes. /// /// /// The ADO.NET default command timeout is ~30 seconds. On a large or contended partition the - /// SWITCH dance (which briefly drops UX_AuditLog_EventId) can exceed that and abort - /// mid-flight — leaving the live table without its idempotency-supporting unique index until a - /// later tick's CATCH branch rebuilds it (arch-review 04, S2). A generous maintenance timeout + /// SWITCH batch can exceed that and abort mid-flight, leaving an orphaned staging table for the + /// next tick's CATCH branch to clean up (arch-review 04, S2). A generous maintenance timeout /// lets the metadata-only switch complete rather than self-locking. Resolved via /// , clamped to a 1-minute floor. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 6aa0e707..72e31a66 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -34,6 +34,62 @@ public interface IAuditLogRepository /// 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). @@ -60,36 +116,32 @@ public interface IAuditLogRepository /// /// /// - /// Drop-and-rebuild dance. UX_AuditLog_EventId is intentionally - /// non-partition-aligned (it lives on [PRIMARY] so single-column - /// EventId uniqueness — required by — - /// can be enforced cheaply). SQL Server rejects - /// ALTER TABLE … SWITCH PARTITION 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. + /// 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. /// /// - /// Outage window. The dance briefly removes the unique index, so - /// concurrent 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 + /// 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 - /// 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 /// - /// (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 UX_AuditLog_EventId 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). /// /// Cancellation token. /// A task that resolves to the approximate number of rows discarded by the partition switch. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs index fa8299de..9165e576 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs @@ -46,10 +46,25 @@ public interface INotificationOutboxRepository Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default); /// - /// Marks modified and persists it (status transitions). - /// Commits internally — this call is its own transaction. + /// Persists 's delivery-state columns — + /// Status, RetryCount, LastError, ResolvedTargets, + /// LastAttemptAt, NextAttemptAt, DeliveredAt. Commits + /// internally — this call is its own transaction. /// - /// The notification to update. + /// + /// Scope is deliberately narrow. 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 nvarchar(max) 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. + /// + /// The notification whose delivery state should be persisted. /// Cancellation token. /// A task that completes when the notification has been persisted. Task UpdateAsync(Notification n, CancellationToken cancellationToken = default); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index 9b86006e..325ab596 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -172,15 +172,18 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration new { e.EventId, e.OccurredAtUtc }); - builder.HasIndex(e => e.EventId) - .IsUnique() - .HasDatabaseName("UX_AuditLog_EventId"); - // Index names are locked for reconciliation/migration discoverability. The // column SETS migrate to the canonical/computed shape (alog.md §4 semantics // preserved): Channel→Category, Site/Node/Execution/ParentExecution now read diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs new file mode 100644 index 00000000..fc39c29b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs @@ -0,0 +1,2090 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260815004957_AlignAuditLogEventIdUniqueness")] + partial class AlignAuditLogEventIdUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_SiteCalls_NonTerminal") + .HasFilter("[TerminalAtUtc] IS NULL"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAtUtc"), new[] { "SourceSite", "SourceNode", "Status" }); + + b.HasIndex("TerminalAtUtc") + .HasDatabaseName("IX_SiteCalls_Terminal") + .HasFilter("[TerminalAtUtc] IS NOT NULL"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("InstanceId"); + + b.ToTable("PendingDeployments", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HourStartUtc") + .HasColumnType("datetime2"); + + b.Property("MaxValue") + .HasColumnType("float"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("MinValue") + .HasColumnType("float"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("HourStartUtc") + .HasDatabaseName("IX_KpiRollupHourly_Hour"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc") + .IsUnique() + .HasDatabaseName("IX_KpiRollupHourly_Series"); + + b.ToTable("KpiRollupHourly", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Captured"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Series"); + + b.ToTable("KpiSample", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountSid") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApiBaseUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthToken") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("FromNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("MessagingServiceSid") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.ToTable("SmsConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("OAuth2Authority") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OAuth2Scope") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Transport") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedSchemas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DecidedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutionError") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)"); + + b.Property("OperatorComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("OperatorUser") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("SiteId") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime2"); + + b.Property("TagPath") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4000) + .IsUnicode(false) + .HasColumnType("varchar(4000)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("VerifierComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("VerifierUser") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("SiteId") + .HasDatabaseName("IX_PendingSecuredWrites_Site"); + + b.HasIndex("Status", "SubmittedAtUtc") + .HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted"); + + b.ToTable("PendingSecuredWrites", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.cs new file mode 100644 index 00000000..44495515 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// Makes dbo.AuditLog's EventId uniqueness partition-aligned by + /// dropping the non-aligned UX_AuditLog_EventId and leaving the clustered + /// PK_AuditLog (EventId, OccurredAtUtc) — already aligned on + /// ps_AuditLog_Month(OccurredAtUtc) — as the sole enforcement. + /// + /// + /// + /// Why. ALTER TABLE … SWITCH PARTITION refuses to run while a + /// non-aligned index exists on the table, so the monthly retention purge + /// (AuditLogRepository.SwitchOutPartitionAsync) had to DROP + /// UX_AuditLog_EventId, switch, and then CREATE it again — an OFFLINE + /// whole-table unique-index build, inside the switch transaction, blocking every + /// audit writer for its duration. It also opened a window in which the index that + /// backs ingest idempotency did not exist at all, and a mid-dance failure could + /// leave the live table without it until a later tick's CATCH branch repaired it. + /// With alignment there is nothing to drop, so the switch is metadata-only and the + /// purge stops competing with ingest. + /// + /// + /// Why dropping it is safe — EventId is globally unique by construction. + /// The composite key enforces uniqueness of the PAIR, not of EventId alone, so in + /// principle the same EventId could now be stored twice under two different + /// OccurredAtUtc values (in two different partitions). That cannot happen + /// here: EventId is a GUID minted ONCE at the emitting site, in the same + /// operation that stamps OccurredAtUtc, and both travel together verbatim + /// through telemetry and reconciliation — nothing downstream re-stamps either + /// field. A given EventId therefore always arrives with the same OccurredAtUtc and + /// can only ever map to one partition, which makes pair-uniqueness equivalent to + /// EventId-uniqueness for every row this system produces. GUID collision across + /// partitions is not a real risk. + /// + /// + /// The idempotency probe still seeks. Both ingest forms test + /// WHERE EventId = @id, which is the LEADING column of the clustered PK, so + /// the probe remains an index seek. The cost changes shape rather than order: it + /// becomes one seek per partition (the partition column is not in the predicate, so + /// SQL Server cannot eliminate partitions) instead of a single seek on a + /// non-partitioned index. Against a monthly scheme that is a couple of dozen + /// shallow B-tree seeks — cheap, and paid on a path that now issues one statement + /// per telemetry packet rather than one per row. + /// + /// + /// Edition note. The alternative remedy — keeping the non-aligned index and + /// rebuilding it with ONLINE = ON outside the switch transaction — requires + /// Enterprise (or Azure SQL / Developer) edition; online index rebuild is not + /// available on Standard, which this deployment does not guarantee. Alignment + /// needs no edition-specific feature and removes the rebuild entirely, so it is + /// preferred regardless of edition. + /// + /// + /// Down is a faithful reverse and recreates the index on [PRIMARY] + /// exactly as CollapseAuditLogToCanonical created it. Reverting also + /// reinstates the SWITCH incompatibility, so the purge's guarded defensive + /// DROP INDEX (retained in SwitchOutPartitionAsync for databases + /// restored from pre-alignment backups) would remove it again on the next purge. + /// The partition function/scheme (pf_AuditLog_Month / + /// ps_AuditLog_Month) and every aligned index are untouched by both + /// directions. + /// + /// + public partial class AlignAuditLogEventIdUniqueness : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Raw, existence-guarded SQL rather than the scaffolded DropIndex: the + // AuditLog table is raw-SQL managed (partition scheme, persisted computed + // columns, append-only role grants), so its migrations stay explicit and + // re-runnable. The guard also lets this apply cleanly to a database whose + // index was already removed by the purge path's defensive cleanup. + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.indexes + WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.indexes + WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) + CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index dccfa59e..95d418ab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -1802,10 +1802,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasDatabaseName("IX_AuditLog_CorrelationId") .HasFilter("[CorrelationId] IS NOT NULL"); - b.HasIndex("EventId") - .IsUnique() - .HasDatabaseName("UX_AuditLog_EventId"); - b.HasIndex("ExecutionId") .HasDatabaseName("IX_AuditLog_Execution"); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index a165319c..a38a6b9c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -1,5 +1,8 @@ +using System.Data; +using System.Text; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; @@ -17,15 +20,32 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; /// public class AuditLogRepository : IAuditLogRepository { - // SQL Server error numbers for duplicate-key violations on - // UX_AuditLog_EventId. 2601 is a unique-index violation; 2627 is a - // primary-key/unique-constraint violation. The IF NOT EXISTS … INSERT - // pattern has a check-then-act race window — two sessions can both pass - // the EXISTS check and then both attempt the INSERT — and the loser - // surfaces as one of these errors. Idempotency demands we swallow them. + // SQL Server error numbers for duplicate-key violations on the + // partition-aligned clustered PK_AuditLog (EventId, OccurredAtUtc). + // 2601 is a unique-index violation; 2627 is a primary-key/unique-constraint + // violation. The IF NOT EXISTS … INSERT pattern has a check-then-act race + // window — two sessions can both pass the EXISTS check and then both attempt + // the INSERT — and the loser surfaces as one of these errors. Idempotency + // demands we swallow them. private const int SqlErrorUniqueIndexViolation = 2601; private const int SqlErrorPrimaryKeyViolation = 2627; + // Rows per set-based ingest statement. Ten bound parameters per row against + // SQL Server's 2,100-parameter ceiling leaves ample headroom at 100 rows + // (1,000 parameters) while still collapsing a typical telemetry packet into + // a single round trip. Larger chunks buy little — the win is round-trip + // elimination, not statement size — and would push plan-cache churn up + // (one cached plan per distinct row count). + private const int IngestChunkRows = 100; + + // Ordinal-stable column list shared by the single-row and set-based inserts. + // The five persisted computed columns (Kind/Status/SourceSiteId/ExecutionId/ + // ParentExecutionId) plus the non-persisted IngestedAtUtc are derived + // server-side from DetailsJson and must NEVER appear here — writing a + // computed column is an error. + private const string CanonicalColumnList = + "EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson"; + private readonly ScadaBridgeDbContext _context; private readonly ILogger _logger; @@ -83,7 +103,7 @@ VALUES { // Two concurrent sessions both passed the IF NOT EXISTS check and // both attempted the INSERT — the loser raises 2601/2627 against - // UX_AuditLog_EventId. First-write-wins idempotency is already the + // the clustered PK. First-write-wins idempotency is already the // documented contract for this method, so the race outcome is // semantically a no-op. Swallow at Debug; other SqlExceptions // bubble. @@ -95,6 +115,196 @@ VALUES } } + /// + public async Task InsertManyIfNotExistsAsync( + IReadOnlyList events, + TimeSpan? commandTimeout = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(events); + + if (events.Count == 0) + { + return 0; + } + + // De-duplicate WITHIN the packet before the statement is built. The + // set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are + // ALREADY committed, so two copies of one EventId inside a single VALUES + // constructor would both pass the anti-semi-join and collide on the + // clustered PK — losing the whole statement to a duplicate-key fault. + // First-write-wins matches the single-row contract exactly (a later copy + // of the same EventId is by definition the same immutable append-only + // row), so keeping the first occurrence is not merely convenient, it is + // the documented semantics. + var seen = new HashSet(events.Count); + var distinct = new List(events.Count); + foreach (var evt in events) + { + if (evt is not null && seen.Add(evt.EventId)) + { + distinct.Add(evt); + } + } + + var inserted = 0; + for (var offset = 0; offset < distinct.Count; offset += IngestChunkRows) + { + var length = Math.Min(IngestChunkRows, distinct.Count - offset); + var chunk = distinct.GetRange(offset, length); + + try + { + inserted += await InsertChunkAsync(chunk, commandTimeout, ct).ConfigureAwait(false); + } + catch (SqlException ex) when ( + ex.Number == SqlErrorUniqueIndexViolation + || ex.Number == SqlErrorPrimaryKeyViolation) + { + // A CONCURRENT writer committed one of this chunk's EventIds + // between the anti-semi-join and the insert (the same + // check-then-act window the single-row path documents), and the + // whole set-based statement rolled back with it. Fall back to + // the per-row path so the rows that are genuinely new still land + // — the batch is a throughput optimisation, never a correctness + // dependency. Every row is retried, including the one that + // collided, because InsertIfNotExistsAsync swallows its own + // duplicate-key fault as a no-op. + _logger.LogDebug( + ex, + "Set-based audit ingest chunk of {Count} row(s) hit a duplicate-key violation (error {SqlErrorNumber}); falling back to per-row inserts.", + chunk.Count, + ex.Number); + + foreach (var evt in chunk) + { + await InsertIfNotExistsAsync(evt, ct).ConfigureAwait(false); + } + } + } + + return inserted; + } + + /// + /// Executes one set-based idempotent insert: a VALUES table constructor + /// anti-semi-joined against the committed rows, so a whole telemetry packet + /// costs ONE round trip instead of one IF NOT EXISTS … INSERT per row. + /// + /// + /// Raw ADO.NET (rather than ExecuteSqlInterpolated) because the + /// statement's parameter count varies with the chunk size and every parameter + /// needs an explicit : the VALUES constructor's column + /// types are inferred from the first row's parameters, so leaving a null + /// Target/SourceNode untyped would give the derived column the + /// wrong type and defeat the seek on the anti-semi-join. The command enlists + /// in the DbContext's ambient transaction when one is open — the cached + /// telemetry dual-write runs the audit insert and the SiteCalls upsert inside + /// a single transaction and both must commit or roll back together. + /// + private async Task InsertChunkAsync( + IReadOnlyList chunk, TimeSpan? commandTimeout, CancellationToken ct) + { + var sql = new StringBuilder(256 + (chunk.Count * 64)); + sql.Append("INSERT INTO dbo.AuditLog (").Append(CanonicalColumnList).Append(")\n"); + sql.Append("SELECT v.EventId, v.OccurredAtUtc, v.Actor, v.Action, v.Outcome, v.Category, "); + sql.Append("v.Target, v.SourceNode, v.CorrelationId, v.DetailsJson\n"); + sql.Append("FROM (VALUES\n"); + for (var i = 0; i < chunk.Count; i++) + { + if (i > 0) + { + sql.Append(",\n"); + } + + sql.Append(" (@e").Append(i) + .Append(",@t").Append(i) + .Append(",@a").Append(i) + .Append(",@n").Append(i) + .Append(",@o").Append(i) + .Append(",@c").Append(i) + .Append(",@g").Append(i) + .Append(",@s").Append(i) + .Append(",@r").Append(i) + .Append(",@d").Append(i) + .Append(')'); + } + + sql.Append("\n) AS v (").Append(CanonicalColumnList).Append(")\n"); + sql.Append("WHERE NOT EXISTS (SELECT 1 FROM dbo.AuditLog x WHERE x.EventId = v.EventId);"); + + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + try + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql.ToString(); + cmd.Transaction = _context.Database.CurrentTransaction?.GetDbTransaction(); + if (commandTimeout is { } timeout) + { + cmd.CommandTimeout = (int)timeout.TotalSeconds; + } + + for (var i = 0; i < chunk.Count; i++) + { + var evt = chunk[i]; + + // Same canonical projection as the single-row path: UTC-kind + // OccurredAtUtc, empty Actor collapses to NULL, Outcome/Category + // bound as their varchar storage form. + var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc); + object actor = string.IsNullOrEmpty(evt.Actor) ? DBNull.Value : evt.Actor; + + AddParameter(cmd, "@e" + i, SqlDbType.UniqueIdentifier, size: 0, evt.EventId); + AddParameter(cmd, "@t" + i, SqlDbType.DateTime2, size: 0, occurred); + AddParameter(cmd, "@a" + i, SqlDbType.NVarChar, size: 256, actor); + AddParameter(cmd, "@n" + i, SqlDbType.VarChar, size: 64, evt.Action); + AddParameter(cmd, "@o" + i, SqlDbType.VarChar, size: 16, evt.Outcome.ToString()); + AddParameter(cmd, "@c" + i, SqlDbType.VarChar, size: 32, evt.Category); + AddParameter(cmd, "@g" + i, SqlDbType.NVarChar, size: 256, evt.Target); + AddParameter(cmd, "@s" + i, SqlDbType.VarChar, size: 64, evt.SourceNode); + AddParameter(cmd, "@r" + i, SqlDbType.UniqueIdentifier, size: 0, evt.CorrelationId); + AddParameter(cmd, "@d" + i, SqlDbType.NVarChar, size: -1, evt.DetailsJson); + } + + return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + } + + /// + /// Binds one explicitly-typed parameter. A null CLR value binds as + /// while KEEPING its declared , + /// which is what makes the VALUES constructor's derived column types stable + /// regardless of which rows happen to carry nulls. + /// + private static void AddParameter( + System.Data.Common.DbCommand cmd, string name, SqlDbType type, int size, object? value) + { + var p = (SqlParameter)cmd.CreateParameter(); + p.ParameterName = name; + p.SqlDbType = type; + if (size != 0) + { + p.Size = size; + } + + p.Value = value ?? DBNull.Value; + cmd.Parameters.Add(p); + } + /// public async Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) @@ -229,13 +439,23 @@ VALUES /// public async Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) { - // The drop-and-rebuild batch below runs via + // The switch batch below runs via // ExecuteSqlRaw with NO EF user-transaction — it carries its own server-side // BEGIN TRANSACTION / TRY-CATCH / ROLLBACK — so the DbContext's retrying // execution strategy (EnableRetryOnFailure) MAY auto-replay the whole batch on // a transient fault. That replay is safe: every step is IF-EXISTS / IF-NOT-EXISTS // guarded and the staging table is GUID-suffixed, so a re-run is idempotent. // + // ALIGNED UNIQUENESS (WP2.2): there is no longer an index drop/rebuild around + // the SWITCH. EventId uniqueness is now enforced solely by the clustered + // PK_AuditLog (EventId, OccurredAtUtc), which is partition-aligned on + // ps_AuditLog_Month(OccurredAtUtc) — so ALTER TABLE … SWITCH PARTITION has no + // non-aligned index to object to. The former dance dropped UX_AuditLog_EventId, + // switched, then rebuilt it OFFLINE inside the same transaction: a whole-table + // unique-index build blocking every writer for the duration of the purge, and a + // window in which the idempotency-supporting index did not exist at all. Both + // are gone. See migration AlignAuditLogEventIdUniqueness for the reasoning. + // // Maintenance timeout in whole seconds (ADO.NET CommandTimeout unit). Null leaves the // provider default in place. See AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes / // arch-review 04 S2 for why the ~30s default is unsafe for the switch-out dance. @@ -270,8 +490,13 @@ VALUES BEGIN TRY BEGIN TRANSACTION; - -- 1. Drop the non-aligned unique index. ALTER TABLE SWITCH refuses - -- to run while it exists. + -- 1. Defensive cleanup for databases created before + -- AlignAuditLogEventIdUniqueness: the migration drops the + -- non-aligned UX_AuditLog_EventId, but a database restored from an + -- older backup could still carry it and SWITCH refuses to run while + -- a non-aligned unique index exists. Dropping it here is idempotent + -- and permanent — the aligned clustered PK is the only uniqueness + -- enforcement the ingest path needs, so there is nothing to rebuild. IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog; @@ -313,31 +538,19 @@ VALUES -- 4. Drop staging — the rows are discarded here. This is the purge. DROP TABLE dbo.[{stagingTableName}]; - -- 5. Rebuild the non-aligned unique index. Live traffic that hit the - -- table during steps 1-4 saw composite-PK uniqueness only; from - -- here on, single-column EventId uniqueness is restored. - CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY]; - COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; - -- Best-effort staging cleanup. The DROP INDEX in step 1 is now - -- rolled back (so the index is back), but the staging table from - -- step 2 may or may not survive the rollback depending on the - -- failure point. Guard the DROP so a missing staging table doesn't - -- mask the original error. + -- Best-effort staging cleanup. The staging table from step 2 may or + -- may not survive the rollback depending on the failure point. Guard + -- the DROP so a missing staging table doesn't mask the original error. + -- Nothing else needs repairing: uniqueness lives on the clustered PK, + -- which the switch never touches, so a failed purge can no longer + -- leave the live table without its idempotency enforcement. IF OBJECT_ID('dbo.[{stagingTableName}]', 'U') IS NOT NULL DROP TABLE dbo.[{stagingTableName}]; - -- Idempotent index rebuild — covers the niche case where ROLLBACK - -- failed to restore UX_AuditLog_EventId (or the failure happened - -- AFTER the COMMIT, which shouldn't be possible inside this TRY - -- but is cheap insurance). Without this, a failed switch could - -- leave the live table without its idempotency-supporting index. - IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) - CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY]; - -- Surface the original error to the caller — the purge actor logs -- and continues with the next boundary. THROW; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs index b1298cfb..4f16d50c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs @@ -128,6 +128,37 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository var groups = samples.GroupBy(s => new SeriesHourKey( s.Source, s.Metric, s.Scope, s.ScopeKey, TruncateToHour(s.CapturedAtUtc))); + // Preload every rollup row already covering this window in ONE query and + // index it by series+hour (WP2.2). The predecessor issued a + // FirstOrDefaultAsync existence probe PER (series, hour) group — an N+1 + // that scaled with the metric catalogue times the lookback: a 3 h re-fold + // over ~40 series cost ~120 sequential round trips before a single row was + // written. The window is bounded by the caller's small trailing lookback + // and the rollup table holds exactly one row per series-hour, so the + // preload is a narrow range seek on IX_KpiRollupHourly_Series. + // + // Deliberately TRACKED (not a projection): the re-fold path mutates the + // existing entity in place and relies on the change tracker to emit the + // UPDATE. The dictionary's ScopeKey comparison is ordinal where the + // previous SQL predicate used the database collation; both sides are + // written from the same KpiSample.ScopeKey values, so they are + // byte-identical in practice and a residual mismatch degrades to the + // already-handled upsert-race path rather than a wrong aggregate. + var existingRollups = await _context.KpiRollupHourly + .Where(r => r.HourStartUtc >= from && r.HourStartUtc < to) + .ToListAsync(cancellationToken); + + var existingByKey = new Dictionary(existingRollups.Count); + foreach (var row in existingRollups) + { + existingByKey[new SeriesHourKey( + row.Source, + row.Metric, + row.Scope, + row.ScopeKey, + DateTime.SpecifyKind(row.HourStartUtc, DateTimeKind.Utc))] = row; + } + foreach (var group in groups) { var key = group.Key; @@ -143,18 +174,11 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository var maxValue = group.Max(s => s.Value); var sampleCount = group.Count(); - // Idempotent upsert on the unique series+hour key. The ScopeKey == key.ScopeKey - // comparison matches null against the Global-scope rows (IS NULL) exactly as the - // UNIQUE IX_KpiRollupHourly_Series index treats a null key as participating. - var existing = await _context.KpiRollupHourly.FirstOrDefaultAsync( - r => r.Source == key.Source - && r.Metric == key.Metric - && r.Scope == key.Scope - && r.ScopeKey == key.ScopeKey - && r.HourStartUtc == key.HourStartUtc, - cancellationToken); - - if (existing is null) + // Idempotent upsert on the unique series+hour key, resolved against the + // preloaded dictionary. A null ScopeKey keys the Global-scope rows exactly + // as the UNIQUE IX_KpiRollupHourly_Series index treats a null key as + // participating. + if (!existingByKey.TryGetValue(key, out var existing)) { _context.KpiRollupHourly.Add(new KpiRollupHourly { diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index bc3690a2..90c874e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -140,7 +140,14 @@ VALUES public async Task> GetDueAsync( DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default) { + // AsNoTracking (WP2.2): the dispatcher mutates each row's delivery state in + // memory and persists it through UpdateAsync, which is now a targeted + // server-side ExecuteUpdate and needs no change tracker. Tracking a batch + // of notifications — each carrying an nvarchar(max) Body and TypeData — + // paid for a full snapshot copy per row plus a DetectChanges scan of the + // whole batch on every save. return await _context.Notifications + .AsNoTracking() .Where(n => n.Status == NotificationStatus.Pending || (n.Status == NotificationStatus.Retrying && n.NextAttemptAt != null @@ -150,22 +157,59 @@ VALUES .ToListAsync(cancellationToken); } - /// - public async Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default) - => await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken); - /// public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default) { - _context.Notifications.Update(n); - await _context.SaveChangesAsync(cancellationToken); + ArgumentNullException.ThrowIfNull(n); + + // Targeted server-side UPDATE of the seven mutable delivery-state columns + // (WP2.2). The predecessor called DbSet.Update(n) + SaveChanges, which + // marks EVERY property modified and rewrites all 21 columns — including + // the immutable nvarchar(max) Body/TypeData payloads — on every single + // delivery attempt. ExecuteUpdate also bypasses the change tracker + // entirely, so it composes with the untracked GetDueAsync read. + // + // Immutable-by-contract columns (NotificationId, Type, ListName, Subject, + // Body, TypeData, Source*, Origin*, SiteEnqueuedAt, CreatedAt) are + // deliberately absent — see the interface contract: nothing in the + // notification lifecycle ever changes them, and omitting them is what + // makes the write narrow. + var status = n.Status; + var retryCount = n.RetryCount; + var lastError = n.LastError; + var resolvedTargets = n.ResolvedTargets; + var lastAttemptAt = n.LastAttemptAt; + var nextAttemptAt = n.NextAttemptAt; + var deliveredAt = n.DeliveredAt; + + var notificationId = n.NotificationId; + + await _context.Notifications + .Where(row => row.NotificationId == notificationId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(row => row.Status, status) + .SetProperty(row => row.RetryCount, retryCount) + .SetProperty(row => row.LastError, lastError) + .SetProperty(row => row.ResolvedTargets, resolvedTargets) + .SetProperty(row => row.LastAttemptAt, lastAttemptAt) + .SetProperty(row => row.NextAttemptAt, nextAttemptAt) + .SetProperty(row => row.DeliveredAt, deliveredAt), + cancellationToken); } + /// + public async Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default) + => await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken); + /// public async Task<(IReadOnlyList Rows, int TotalCount)> QueryAsync( NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default) { - var query = _context.Notifications.AsQueryable(); + // AsNoTracking (WP2.2): this is the Central UI's read-only list page. The + // rows are projected to the wire and never saved, so tracking them cost a + // snapshot copy of every nvarchar(max) Body in the page for nothing. + var query = _context.Notifications.AsNoTracking().AsQueryable(); if (filter.Status is { } status) { @@ -218,8 +262,14 @@ VALUES var totalCount = await query.CountAsync(cancellationToken); + // NotificationId breaks CreatedAt ties so the OFFSET window is deterministic — + // without it two rows sharing a CreatedAt could appear on both pages or on + // neither. (This page keeps OFFSET paging rather than the sibling repos' + // keyset cursor because its contract surfaces a page number and a total + // count, neither of which a keyset cursor can express.) var rows = await query .OrderByDescending(n => n.CreatedAt) + .ThenByDescending(n => n.NotificationId) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -244,7 +294,24 @@ VALUES // One conditional-aggregation pass replaces four sequential COUNT round trips: // each metric is a COUNT(CASE WHEN THEN 1 END) over the same scan // (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group). + // + // WP2.2 — the aggregation is now PREDICATE-RESTRICTED instead of scanning the + // whole table. Every KPI here is about the live queue (Pending/Retrying), the + // parked backlog, or the last delivery interval; the overwhelming bulk of the + // Notifications table is historical Delivered and Discarded rows that + // contribute to NONE of them. The pre-filter below is the union of the + // metric-contributing predicates, which lets the optimizer seek the status + // index for the live/parked legs and the filtered + // (DeliveredAt) WHERE Status='Delivered' index (WP1.4) for the interval leg, + // instead of paying a full scan whose cost grows with retained history. + // Mirrors the pre-filter ComputePerSiteKpisAsync/ComputePerNodeKpisAsync + // already use — the global snapshot was the odd one out. var counts = await _context.Notifications + .Where(n => n.Status == NotificationStatus.Pending + || n.Status == NotificationStatus.Retrying + || n.Status == NotificationStatus.Parked + || (n.Status == NotificationStatus.Delivered + && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)) .GroupBy(_ => 1) .Select(g => new { diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index 29137a77..ac31c9e0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -72,49 +72,36 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository var idText = siteCall.TrackedOperationId.Value.ToString("D"); var incomingRank = GetRankOrThrow(siteCall.Status); - // Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync - // this is check-then-act so a duplicate-key violation may surface under - // concurrent inserts on the same id — caught + logged at Debug. + // ONE round trip, UPDATE-first (WP2.2). The predecessor issued an + // unconditional IF NOT EXISTS … INSERT and THEN a monotonic UPDATE — two + // statements, two round trips, on every single packet, of which the insert + // half was wasted work for every packet after the first (the steady state: + // a cached call emits Submitted → Forwarded → Attempted → terminal, so + // three of four packets hit an existing row). // - // SourceNode-stamping: the column is included in the INSERT - // column list / VALUES so a fresh row carries the originating node - // name (node-a/node-b for site rows). A null SourceNode (legacy hosts - // / unstamped reconciled rows) writes NULL straight through. - try - { - await _context.Database.ExecuteSqlInterpolatedAsync( - $@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText}) -INSERT INTO dbo.SiteCalls - (TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, - LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc) -VALUES - ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount}, - {siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});", - ct); - } - catch (SqlException ex) when ( - ex.Number == SqlErrorUniqueIndexViolation - || ex.Number == SqlErrorPrimaryKeyViolation) - { - _logger.LogDebug( - ex, - "SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.", - ex.Number, - idText); - } - - // Step 2: monotonic update with a same-rank freshness tiebreaker. The - // CASE expression maps the stored Status string to the same rank table - // the caller uses. We mutate when EITHER the incoming rank is strictly - // greater, OR the incoming rank equals the stored rank AND that rank is - // non-terminal (< TerminalRank) AND the incoming UpdatedAtUtc is strictly - // newer than the stored one — so a retrying call's Attempted-phase - // RetryCount/LastError/HttpStatus stay live instead of freezing at the - // first Attempted packet. Terminal ranks are excluded from the - // tiebreaker, so a later terminal NEVER overwrites an earlier one; equal - // stamps are inert (idempotent replay) and a lower rank is always a no-op. + // The combined batch below runs UPDATE first and inserts only when the + // UPDATE matched nothing AND the row genuinely does not exist. The + // NOT EXISTS re-check is load-bearing: @@ROWCOUNT = 0 is ALSO what a + // monotonic REJECTION looks like (a stale or regressive packet against an + // existing row), and inserting there would resurrect a row the guard just + // refused. Both statements ship in one command text, so this is one + // round trip, not two. // - // SourceNode-stamping: SourceNode is updated via + // Monotonic update semantics are unchanged: mutate when EITHER the + // incoming rank is strictly greater, OR the incoming rank equals the + // stored rank AND that rank is non-terminal (< TerminalRank) AND the + // incoming UpdatedAtUtc is strictly newer than the stored one — so a + // retrying call's Attempted-phase RetryCount/LastError/HttpStatus stay + // live instead of freezing at the first Attempted packet. Terminal ranks + // are excluded from the tiebreaker, so a later terminal NEVER overwrites + // an earlier one; equal stamps are inert (idempotent replay) and a lower + // rank is always a no-op. + // + // SourceNode-stamping: the column is included in the INSERT column list / + // VALUES so a fresh row carries the originating node name (node-a/node-b + // for site rows). A null SourceNode (legacy hosts / unstamped reconciled + // rows) writes NULL straight through. On the UPDATE leg SourceNode is + // written via // COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode // when it is non-null, otherwise the stored value — so the column // behaves protectively: a later packet that carries a null @@ -128,8 +115,12 @@ VALUES // lifecycle every packet should carry the same SourceNode value (one // execution, one node) so the "overwrite" path is in practice // idempotent. - await _context.Database.ExecuteSqlInterpolatedAsync( - $@"UPDATE dbo.SiteCalls + try + { + await _context.Database.ExecuteSqlInterpolatedAsync( + $@"DECLARE @updated int; + +UPDATE dbo.SiteCalls SET Status = {siteCall.Status}, RetryCount = {siteCall.RetryCount}, LastError = {siteCall.LastError}, @@ -162,8 +153,40 @@ WHERE TrackedOperationId = {idText} ELSE -1 END) AND {incomingRank} < {TerminalRank} - AND UpdatedAtUtc < {siteCall.UpdatedAtUtc} ) );", - ct); + AND UpdatedAtUtc < {siteCall.UpdatedAtUtc} ) ); + +-- Captured IMMEDIATELY after the UPDATE: @@ROWCOUNT is reset by the next +-- statement, and reading it inline inside a compound IF condition alongside a +-- subquery is not safe (the subquery's own execution can clobber it). +SET @updated = @@ROWCOUNT; + +IF @updated = 0 AND NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText}) +INSERT INTO dbo.SiteCalls + (TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, + LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc) +VALUES + ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount}, + {siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});", + ct); + } + catch (SqlException ex) when ( + ex.Number == SqlErrorUniqueIndexViolation + || ex.Number == SqlErrorPrimaryKeyViolation) + { + // Two concurrent sessions both found the row absent and both raced to + // INSERT; the loser raises 2601/2627 against the TrackedOperationId + // primary key. The winner's row IS the first-write, and this packet's + // content is by construction the same lifecycle state, so the race + // outcome is semantically a no-op. Swallow at Debug — the same + // check-then-act contract the sibling AuditLog/Notification repos + // document. Note the loser's UPDATE leg already ran (against no row), + // so nothing is left half-applied. + _logger.LogDebug( + ex, + "SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; treating as no-op.", + ex.Number, + idText); + } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs index 1752e17e..de74823e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -26,6 +26,22 @@ public static class ServiceCollectionExtensions // registers IDataProtectionProvider as a singleton; resolving it here does not recurse // because key-ring loading is lazy (first Protect/Unprotect), not triggered by // CreateProtector during model building. + // + // POOLING IS DELIBERATELY NOT USED (WP2.2 — verified, not overlooked). + // AddDbContextPool requires a context with a SINGLE public constructor taking + // only DbContextOptions; EF Core constructs pooled instances through + // its own activator and cannot supply anything else. ScadaBridgeDbContext has + // two public constructors and the runtime one takes IDataProtectionProvider, + // because the encrypting value converter for secret-bearing columns is built + // during OnModelCreating from that provider. Worse, the model itself DIFFERS + // between the two constructors (no provider ⇒ no encrypting converter), so a + // pooled activator would silently produce a context that reads secret columns + // as ciphertext. Making this poolable means moving the protector out of the + // constructor and into a DbContextOptions extension — a change to the + // secrets-at-rest path, which is not a performance refactor. The registration + // below (a scoped factory overriding AddDbContext's activator) is what makes + // the provider reach the context at all, and it also bypasses pooling by + // construction. Revisit only alongside a deliberate secrets-plumbing change. services.AddDbContext((serviceProvider, options) => { options.UseSqlServer( diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs index 398c712f..eff92c94 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs @@ -158,6 +158,26 @@ public class SiteCallAuditActor : ReceiveActor /// private readonly Dictionary _reconciliationPinned = new(); + /// + /// Actor-system EventStream captured on the actor thread at handler-registration + /// time. The reconciliation pass runs off-mailbox and publishes the pinned-state + /// transition from there, so it must not reach through Context. + /// + private Akka.Event.EventStream _eventStream = null!; + + /// + /// Single-flight guard for the off-mailbox reconciliation pass. Raised on the + /// actor thread when a tick launches a pass and lowered on the actor thread + /// when the piped arrives, so the + /// per-site cursor/pinned dictionaries the pass mutates are only ever touched + /// by one task at a time and the mailbox supplies the memory barrier between + /// consecutive passes. + /// + private bool _reconciling; + + /// Single-flight guard for the off-mailbox terminal-row purge pass. + private bool _purging; + private ICancelable? _reconciliationTimer; private ICancelable? _purgeTimer; @@ -338,8 +358,79 @@ public class SiteCallAuditActor : ReceiveActor // the daily terminal-row purge. Handlers stay alive across faults via // their own per-site / per-tick try/catch (mirroring the ingest path); // the timers are only started when their collaborators are available. - ReceiveAsync(_ => OnReconciliationTickAsync()); - ReceiveAsync(_ => OnPurgeTickAsync()); + // + // OFF-MAILBOX (WP2.2). Both passes run as PipeTo-completed background + // tasks behind a single-flight guard rather than as ReceiveAsync bodies. + // A ReceiveAsync handler occupies the actor for its whole duration, and a + // reconciliation pass is unbounded work — every site, up to + // MaxReconciliationPagesPerTick network pulls each, one upsert per row. + // Post-outage catch-up therefore blocked telemetry ingest, UI queries and + // KPI Asks behind it until the drain finished, and those callers timed out + // rather than queued. NotificationOutboxActor's dispatch sweep is the + // in-repo reference for this shape. + Receive(_ => HandleReconciliationTick()); + Receive(_ => _reconciling = false); + Receive(_ => HandlePurgeTick()); + Receive(_ => _purging = false); + + // Captured on the actor thread so the background passes never touch + // Context off-thread. EventStream itself is thread-safe. + _eventStream = Context.System.EventStream; + } + + /// + /// Launches a reconciliation pass unless one is already in flight, dropping + /// the tick if so. Overlapping passes are not merely wasteful — they would + /// race on the per-site cursor and pinned-latch dictionaries, which the + /// single-flight guard keeps confined to one task at a time (the guard itself + /// is only ever mutated on the actor thread: raised here, lowered by the + /// piped completion message). + /// + private void HandleReconciliationTick() + { + if (_reconciling) + { + return; + } + + _reconciling = true; + + // OnReconciliationTickAsync swallows its own per-site errors, but the + // failure projection is kept as a belt-and-braces guard so even a faulted + // task still lowers the guard — otherwise reconciliation would wedge + // permanently after a single unexpected throw. + OnReconciliationTickAsync().PipeTo( + Self, + success: () => ReconciliationComplete.Instance, + failure: ex => + { + _logger.LogError(ex, "SiteCallAudit reconciliation pass faulted unexpectedly."); + return ReconciliationComplete.Instance; + }); + } + + /// + /// Launches a purge pass unless one is already in flight. Same single-flight + /// discipline as : a purge that outlives + /// its interval (a large catch-up after an outage) must not stack. + /// + private void HandlePurgeTick() + { + if (_purging) + { + return; + } + + _purging = true; + + OnPurgeTickAsync().PipeTo( + Self, + success: () => PurgeComplete.Instance, + failure: ex => + { + _logger.LogError(ex, "SiteCallAudit purge pass faulted unexpectedly."); + return PurgeComplete.Instance; + }); } /// @@ -743,7 +834,10 @@ public class SiteCallAuditActor : ReceiveActor } _reconciliationPinned[siteId] = pinned; - Context.System.EventStream.Publish(new SiteCallReconciliationPinnedChanged(siteId, pinned)); + + // _eventStream, not Context.System.EventStream: this runs on the + // off-mailbox reconciliation pass, where Context must not be touched. + _eventStream.Publish(new SiteCallReconciliationPinnedChanged(siteId, pinned)); } // ── Piece B: daily terminal-row purge scheduler ── @@ -1452,6 +1546,24 @@ public class SiteCallAuditActor : ReceiveActor public static readonly PurgeTick Instance = new(); private PurgeTick() { } } + + /// + /// Piped back to Self when an off-mailbox reconciliation pass ends + /// (successfully or not) so the single-flight guard is lowered on the actor + /// thread rather than from the background task. + /// + internal sealed class ReconciliationComplete + { + public static readonly ReconciliationComplete Instance = new(); + private ReconciliationComplete() { } + } + + /// Purge counterpart of . + internal sealed class PurgeComplete + { + public static readonly PurgeComplete Instance = new(); + private PurgeComplete() { } + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 356d9619..e2d2c927 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -121,6 +121,105 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture { repeated, other, repeated, repeated }; + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new IngestAuditEventsCommand(batch), TestActor); + + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal(4, reply.AcceptedEventIds.Count); + Assert.True( + new[] { repeated.EventId, other.EventId }.ToHashSet() + .SetEquals(reply.AcceptedEventIds.ToHashSet())); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(2, rows.Count); + } + + [SkippableFact] + public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // A site whose ack was lost re-delivers the identical packet on the next + // drain, and the reconciliation pull can re-deliver it a third time. Each + // replay must ack fully (so the site can finally flip its rows to + // Forwarded) while writing nothing new. + var siteId = NewSiteId(); + var events = Enumerable.Range(0, 6).Select(_ => NewEvent(siteId)).ToList(); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + for (var attempt = 0; attempt < 3; attempt++) + { + actor.Tell(new IngestAuditEventsCommand(events), TestActor); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal(6, reply.AcceptedEventIds.Count); + Assert.True( + events.Select(e => e.EventId).ToHashSet() + .SetEquals(reply.AcceptedEventIds.ToHashSet())); + } + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(6, rows.Count); + } + + [SkippableFact] + public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Partially-overlapping packets are the normal reconciliation shape: the + // pull cursor re-serves a tail the push already delivered. The overlap + // must be a silent no-op and the new rows must land. + var siteId = NewSiteId(); + var first = Enumerable.Range(0, 4).Select(_ => NewEvent(siteId)).ToList(); + var second = first.Skip(2).Concat( + Enumerable.Range(0, 3).Select(_ => NewEvent(siteId))).ToList(); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new IngestAuditEventsCommand(first), TestActor); + ExpectMsg(TimeSpan.FromSeconds(10)); + + actor.Tell(new IngestAuditEventsCommand(second), TestActor); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal(5, reply.AcceptedEventIds.Count); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(7, rows.Count); + } + [SkippableFact] public async Task Receive_Sets_IngestedAtUtc_Before_Insert() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs index 2e3a3a91..59c702e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs @@ -28,20 +28,19 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// The oldest partition (Jan) is removed. /// Newer partitions (Feb + Mar) are untouched. -/// The UX_AuditLog_EventId unique index survives the -/// drop-and-rebuild dance. +/// The switch leaves the aligned clustered PK_AuditLog intact and +/// does NOT (re)create the non-aligned UX_AuditLog_EventId. /// remains -/// idempotent against the rebuilt index after the purge. +/// idempotent against the aligned key after the purge. /// /// /// /// The brief calls out that direct INSERTs bypass the writer role's INSERT-only /// grant; the fixture connects as sa (see /// 's default admin connection string), so -/// the seed step does not need the writer role at all. The drop-and-rebuild -/// dance itself runs under the same admin connection because the test owns -/// the database — the role granularity is exercised in the repository tests, -/// not here. +/// the seed step does not need the writer role at all. The switch batch itself +/// runs under the same admin connection because the test owns the database — +/// the role granularity is exercised in the repository tests, not here. /// public class PartitionPurgeTests : TestKit, IClassFixture { @@ -110,22 +109,39 @@ VALUES } /// - /// Asserts that UX_AuditLog_EventId exists in - /// sys.indexes. The drop-and-rebuild dance briefly removes the - /// index inside its transaction; this check is meant to fire AFTER the - /// actor's purge tick has committed so the rebuilt index is observable. + /// Asserts the post-purge index state: the partition-ALIGNED clustered + /// PK_AuditLog is intact and the non-aligned UX_AuditLog_EventId + /// is absent (WP2.2 — AlignAuditLogEventIdUniqueness). /// - private static async Task AssertUxIndexExistsAsync(SqlConnection conn) + /// + /// The purge used to bracket its SWITCH with a DROP/CREATE of + /// UX_AuditLog_EventId, because a non-aligned unique index blocks + /// ALTER TABLE … SWITCH PARTITION — an offline whole-table index build + /// inside the switch transaction, plus a window with no idempotency index at + /// all. Uniqueness now rides the aligned clustered PK, so the switch is + /// metadata-only. Asserting the index's ABSENCE is what keeps that property: + /// anything that recreates it silently reinstates the rebuild. + /// + private static async Task AssertAlignedUniquenessAsync(SqlConnection conn) { await using var cmd = conn.CreateCommand(); cmd.CommandText = @" -SELECT COUNT(*) -FROM sys.indexes -WHERE name = 'UX_AuditLog_EventId' - AND object_id = OBJECT_ID('dbo.AuditLog');"; - var raw = await cmd.ExecuteScalarAsync(); - var count = Convert.ToInt32(raw); - Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}."); +SELECT + (SELECT COUNT(*) FROM sys.indexes + WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) AS NonAligned, + (SELECT COUNT(*) FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1) AS ClusteredPk;"; + await using var reader = await cmd.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + var nonAligned = reader.GetInt32(0); + var clusteredPk = reader.GetInt32(1); + + Assert.True( + nonAligned == 0, + $"UX_AuditLog_EventId must NOT exist post-purge (it blocks SWITCH PARTITION); sys.indexes count was {nonAligned}."); + Assert.True( + clusteredPk == 1, + $"The aligned clustered PK_AuditLog must survive the purge; sys.indexes primary-key count was {clusteredPk}."); } private IActorRef CreateActor( @@ -255,20 +271,19 @@ WHERE name = 'UX_AuditLog_EventId' } // --------------------------------------------------------------------- - // 2. EndToEnd_UxIndexRebuilt_AfterPurge + // 2. EndToEnd_AlignedUniqueness_AfterPurge // --------------------------------------------------------------------- [SkippableFact] - public async Task EndToEnd_UxIndexRebuilt_AfterPurge() + public async Task EndToEnd_AlignedUniqueness_AfterPurge() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Same shape as test 1 — purge the Jan-2026 partition and then assert the - // UX_AuditLog_EventId index is still present. RetentionDays is computed - // dynamically so the threshold always lands near 2026-01-20 (see SeedOccurredAt()). - // The drop-and-rebuild dance briefly removes the index inside its transaction - // (the SWITCH PARTITION step requires the non-aligned unique index to be absent), - // but step 5 rebuilds it before committing. + // index state. RetentionDays is computed dynamically so the threshold always + // lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches + // no index at all: uniqueness rides the aligned clustered PK, so there is + // nothing to drop before the SWITCH and nothing to rebuild after it. var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8); var oldEventId = Guid.NewGuid(); var (oldOccurred, _, _, retentionDays) = SeedOccurredAt(); @@ -305,7 +320,7 @@ WHERE name = 'UX_AuditLog_EventId' // Open a fresh connection (the actor's pool is owned by EF) and // assert the index is present post-purge. await using var check = _fixture.OpenConnection(); - await AssertUxIndexExistsAsync(check); + await AssertAlignedUniquenessAsync(check); } // --------------------------------------------------------------------- @@ -320,7 +335,7 @@ WHERE name = 'UX_AuditLog_EventId' // Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for // a fresh recent EventId. The second call must be a no-op (duplicate-key collision // swallowed by the repository, per M2 Bundle A's race-fix) — which means the - // rebuilt UX_AuditLog_EventId unique index is functioning as intended. + // aligned clustered PK is still enforcing uniqueness as intended. // RetentionDays is computed dynamically so the threshold always lands near // 2026-01-20 (see SeedOccurredAt()). var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8); @@ -357,11 +372,11 @@ WHERE name = 'UX_AuditLog_EventId' max: TimeSpan.FromSeconds(30)); // Settle then exercise InsertIfNotExistsAsync twice for the same - // EventId. The repository's idempotency relies on - // UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT - // race window resolves to a duplicate-key violation the repo - // swallows. If the index were missing here, two rows would land - // and the second InsertIfNotExistsAsync would silently double-insert. + // EventId. The repository's idempotency relies on the aligned clustered + // PK (EventId, OccurredAtUtc) being intact so the IF NOT EXISTS … INSERT + // race window resolves to a duplicate-key violation the repo swallows. + // If the switch had disturbed that key, two rows would land and the second + // InsertIfNotExistsAsync would silently double-insert. await Task.Delay(TimeSpan.FromMilliseconds(500)); var freshEventId = Guid.NewGuid(); @@ -482,7 +497,7 @@ WHERE name = 'UX_AuditLog_EventId' /// /// Task 4 (arch-review 04, S2): proves the explicit- maintenance-timeout /// overload of runs the real - /// drop-and-rebuild dance to completion against SQL Server — the old row is purged, the kept row + /// staging/switch batch to completion against SQL Server — the old row is purged, the kept row /// survives, and the returned sampled row-count reflects the switched partition. Exercising the /// timeout path end-to-end guards against a regression that only sets the timeout on the sample /// command and forgets the DDL batch (or vice versa). @@ -520,8 +535,8 @@ WHERE name = 'UX_AuditLog_EventId' Assert.DoesNotContain(rows, r => r.EventId == oldEventId); Assert.Contains(rows, r => r.EventId == keptEventId); - // The dance must leave the idempotency-supporting unique index rebuilt. + // The switch must leave uniqueness enforcement exactly as it found it. await using var check = _fixture.OpenConnection(); - await AssertUxIndexExistsAsync(check); + await AssertAlignedUniquenessAsync(check); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index d5446b79..28ce7cf0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -52,22 +52,31 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Fact] - public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups() + public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey() { - // EventId remains globally unique (the idempotency key for - // InsertIfNotExistsAsync) via a dedicated unique index independent of the - // composite PK. + // WP2.2 (AlignAuditLogEventIdUniqueness): the standalone non-aligned + // UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered + // PK (EventId, OccurredAtUtc), which is partition-aligned on + // ps_AuditLog_Month — so ALTER TABLE ... SWITCH PARTITION no longer needs + // an offline index drop/rebuild around every retention purge. EventId is a + // GUID minted once at the site alongside OccurredAtUtc, so pair-uniqueness + // is EventId-uniqueness in practice, and the idempotency probe + // (WHERE EventId = @id) still seeks the clustered key's leading column. + // + // Re-declaring a single-column unique index here would silently reinstate + // the SWITCH incompatibility, so this test pins its absence. var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); Assert.NotNull(entity); - var eventIdIndex = entity!.GetIndexes() - .SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId"); + Assert.DoesNotContain( + entity!.GetIndexes(), + i => i.GetDatabaseName() == "UX_AuditLog_EventId"); - Assert.NotNull(eventIdIndex); - Assert.True(eventIdIndex!.IsUnique); - - var indexedProperty = Assert.Single(eventIdIndex.Properties); - Assert.Equal(nameof(AuditLogRow.EventId), indexedProperty.Name); + var pk = entity.FindPrimaryKey(); + Assert.NotNull(pk); + Assert.Equal( + new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, + pk!.Properties.Select(p => p.Name).ToArray()); } [Fact] @@ -164,7 +173,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable "IX_AuditLog_ParentExecution", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", - "UX_AuditLog_EventId", + // UX_AuditLog_EventId is intentionally absent — dropped by + // AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it. }; Assert.Equal(expected, indexNames); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs index a3d7ddcc..02816828 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs @@ -85,22 +85,27 @@ public class AddAuditLogTableMigrationTests : IClassFixture - /// Verifies all nine named non-clustered indexes exist on the final - /// dbo.AuditLog table after all migrations have been applied. + /// Verifies all eight named non-clustered indexes exist on the final + /// dbo.AuditLog table after all migrations have been applied, and that + /// the non-aligned UX_AuditLog_EventId does NOT. /// The original five indexes were created by AddAuditLogTable; /// the CollapseAuditLogToCanonical (C5, Task 2.5) migration rebuilt /// the table and added IX_AuditLog_Execution, - /// IX_AuditLog_ParentExecution, IX_AuditLog_Node_Occurred, - /// and UX_AuditLog_EventId — nine in total. + /// IX_AuditLog_ParentExecution and IX_AuditLog_Node_Occurred. + /// UX_AuditLog_EventId was created alongside them but dropped again by + /// AlignAuditLogEventIdUniqueness (WP2.2) — its non-alignment forced an + /// offline drop/rebuild around every partition-switch purge, and the clustered + /// PK_AuditLog (EventId, OccurredAtUtc) already enforces the same + /// uniqueness partition-aligned. /// [SkippableFact] - public async Task AppliesMigration_CreatesNineNamedIndexes() + public async Task AppliesMigration_CreatesEightNamedIndexes_AndNoNonAlignedUniqueIndex() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // All nine named non-clustered indexes present on dbo.AuditLog after + // All eight named non-clustered indexes present on dbo.AuditLog after // the full migration history is applied (AddAuditLogTable through - // CollapseAuditLogToCanonical). + // AlignAuditLogEventIdUniqueness). var expected = new[] { // Original five (AddAuditLogTable / AddAuditLogSourceNode): @@ -113,7 +118,6 @@ public class AddAuditLogTableMigrationTests : IClassFixture( + "SELECT COUNT(*) FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + "WHERE o.name = 'AuditLog' AND i.name = 'UX_AuditLog_EventId';"); + Assert.True( + nonAligned == 0, + "UX_AuditLog_EventId must NOT exist after AlignAuditLogEventIdUniqueness — " + + "a non-aligned index blocks ALTER TABLE ... SWITCH PARTITION and reinstates " + + $"the offline drop/rebuild the purge path was freed from; found {nonAligned}."); } [SkippableFact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs index 5ca60fe6..f6fe9500 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs @@ -50,6 +50,16 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests $"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2"); // The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT. Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase)); + // WP2.2: the aggregation must be PREDICATE-RESTRICTED, not an unrestricted + // scan. Every KPI here concerns the live queue, the parked backlog or the + // last delivery interval; historical Delivered/Discarded rows — which are + // the overwhelming bulk of a retained table — contribute to none of them. + // Without the WHERE the query's cost grows with retention rather than with + // the working set. (An index-restricted aggregation was already the shape + // of the per-site/per-node snapshots; the global one was the odd one out.) + Assert.All( + counter.Commands.Where(sql => sql.Contains("COUNT", StringComparison.OrdinalIgnoreCase)), + sql => Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase)); Assert.Equal(3, global.QueueDepth); Assert.NotNull(global.OldestPendingAge); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index adda88ba..9f880125 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -560,6 +560,164 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(1, count); } + // ------------------------------------------------------------------------ + // WP2.2: set-based InsertManyIfNotExistsAsync + // ------------------------------------------------------------------------ + + [SkippableFact] + public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var baseTime = new DateTime(2026, 5, 21, 8, 0, 0, DateTimeKind.Utc); + var events = Enumerable.Range(0, 12) + .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i))) + .ToList(); + + var inserted = await repo.InsertManyIfNotExistsAsync(events); + + Assert.Equal(12, inserted); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Equal(12, rows.Count); + Assert.Equal( + events.Select(e => e.EventId).OrderBy(g => g).ToArray(), + rows.Select(r => r.EventId).OrderBy(g => g).ToArray()); + } + + [SkippableFact] + public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // A set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are + // already COMMITTED, so two copies of one EventId inside a single VALUES + // constructor would both pass the anti-semi-join and collide on the + // clustered PK — taking the whole statement down with them. The repository + // de-duplicates the packet first (first-write-wins, matching the + // single-row contract), which this pins. + var occurred = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc); + var first = NewEvent(siteId, occurredAtUtc: occurred); + var duplicate = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: first.EventId, + occurredAtUtc: occurred, + sourceSiteId: siteId, + errorMessage: "duplicate-within-packet-should-be-ignored"); + var other = NewEvent(siteId, occurredAtUtc: occurred.AddSeconds(1)); + + var inserted = await repo.InsertManyIfNotExistsAsync( + new[] { first, duplicate, other, duplicate }); + + Assert.Equal(2, inserted); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Equal(2, rows.Count); + + // First-write-wins: the surviving row is the FIRST occurrence, so the + // duplicate's ErrorMessage never lands. + var stored = Assert.Single(rows, r => r.EventId == first.EventId); + Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage); + } + + [SkippableFact] + public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var baseTime = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc); + var packet = Enumerable.Range(0, 5) + .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i))) + .ToList(); + + // A site retry / reconciliation pull re-delivers a packet that overlaps + // an already-ingested one. The second call must insert only the genuinely + // new rows and silently skip the rest (first-write-wins across packets). + var firstInserted = await repo.InsertManyIfNotExistsAsync(packet); + var extra = NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(99)); + var secondInserted = await repo.InsertManyIfNotExistsAsync( + packet.Concat(new[] { extra }).ToList()); + + // A third, wholly-redundant replay writes nothing at all. + var thirdInserted = await repo.InsertManyIfNotExistsAsync(packet); + + Assert.Equal(5, firstInserted); + Assert.Equal(1, secondInserted); + Assert.Equal(0, thirdInserted); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Equal(6, rows.Count); + } + + [SkippableFact] + public async Task InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // 250 rows × 10 bound parameters = 2,500 — past SQL Server's 2,100 + // parameter ceiling for a single statement, so this only succeeds if the + // repository chunks. Nulls in the optional columns (Target, SourceNode, + // CorrelationId, Actor) are left null on purpose: the VALUES constructor + // derives its column types from the parameters, so an untyped null would + // surface here as a conversion failure rather than silently. + var baseTime = new DateTime(2026, 5, 21, 11, 0, 0, DateTimeKind.Utc); + var events = Enumerable.Range(0, 250) + .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i))) + .ToList(); + + var inserted = await repo.InsertManyIfNotExistsAsync(events); + + Assert.Equal(250, inserted); + + await using var readContext = CreateContext(); + var count = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .CountAsync(); + + Assert.Equal(250, count); + } + + [SkippableFact] + public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty())); + } + [SkippableFact] public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId() { @@ -622,16 +780,17 @@ public class AuditLogRepositoryTests : IClassFixture // ------------------------------------------------------------------------ // // The partition-switch path replaces M1's NotSupportedException stub with - // the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION → - // DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests + // the production CREATE-staging → SWITCH PARTITION → DROP-staging batch + // documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to + // bracket it: uniqueness rides the partition-ALIGNED clustered PK + // (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests // verify the side effects an outsider can observe: // * rows in the targeted month are removed // * rows in OTHER months are NOT touched - // * UX_AuditLog_EventId still exists after a successful switch + // * no non-aligned UX_AuditLog_EventId is (re)created by a switch // * InsertIfNotExistsAsync's first-write-wins idempotency still holds - // after a switch (the rebuilt index is real) - // * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH - // branch's recovery path runs) + // after a switch (the aligned key really does enforce it) + // * a thrown SqlException leaves the table intact with no orphaned staging [SkippableFact] public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept() @@ -669,7 +828,7 @@ public class AuditLogRepositoryTests : IClassFixture } [SkippableFact] - public async Task SwitchOutPartitionAsync_RebuildsUxIndex_AfterSwitch() + public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); @@ -680,12 +839,23 @@ public class AuditLogRepositoryTests : IClassFixture // the fixture's MSSQL database) don't tread on each other. await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + // WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it + // would reinstate the offline drop/rebuild the purge was freed from and + // block the next SWITCH until something dropped it again. await using var verifyContext = CreateContext(); var indexExists = await ScalarAsync( verifyContext, "SELECT COUNT(*) FROM sys.indexes " + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); - Assert.Equal(1, indexExists); + Assert.Equal(0, indexExists); + + // The aligned clustered PK is what enforces uniqueness now, and the + // switch must leave it untouched. + var pkExists = await ScalarAsync( + verifyContext, + "SELECT COUNT(*) FROM sys.indexes " + + "WHERE name = 'PK_AuditLog' AND object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1;"); + Assert.Equal(1, pkExists); } [SkippableFact] @@ -705,10 +875,11 @@ public class AuditLogRepositoryTests : IClassFixture // Switch out the June 2026 partition (different month, empty). await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); - // Re-attempting the same EventId after the switch must STILL be a no-op - // (UX_AuditLog_EventId is the index that enables idempotency; if the - // rebuild left it broken, this insert would silently produce a duplicate - // row and the count assertion below would catch it). + // Re-attempting the same EventId after the switch must STILL be a no-op. + // The idempotency probe now seeks the aligned clustered PK's leading + // column instead of a dedicated unique index; if the switch had disturbed + // that key, this insert would silently produce a duplicate row and the + // count assertion below would catch it. // C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory // (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies). var dup = ScadaBridgeAuditEventFactory.Create( @@ -734,7 +905,7 @@ public class AuditLogRepositoryTests : IClassFixture } [SkippableFact] - public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException() + public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); @@ -745,8 +916,8 @@ public class AuditLogRepositoryTests : IClassFixture // ALTER TABLE … SWITCH refuses to move rows out of a partition that's // referenced by an FK from another table, raising msg 4928 // ("ALTER TABLE SWITCH statement failed because target table … has a - // foreign key …"). The CATCH branch then rolls back and rebuilds the - // unique index — which the assertion below verifies. + // foreign key …"). The CATCH branch then rolls back and drops the staging + // table — which the assertion below verifies. // // The probe table is uniquely named with a guid suffix so reruns of // this test inside the same fixture DB never collide. We clean it up @@ -786,14 +957,23 @@ public class AuditLogRepositoryTests : IClassFixture await cmd.ExecuteNonQueryAsync(); } - // The CATCH block in the production SQL guarantees UX_AuditLog_EventId - // is rebuilt regardless of which step failed inside the TRY. + // The CATCH block drops the GUID-suffixed staging table regardless of + // which step failed inside the TRY, so a failed purge leaves no orphaned + // AuditLog_Staging_* object behind for the next tick to trip over. await using var verifyContext = CreateContext(); + var orphanedStaging = await ScalarAsync( + verifyContext, + "SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';"); + Assert.Equal(0, orphanedStaging); + + // And it must NOT have (re)created the non-aligned unique index: WP2.2 + // deleted that rebuild entirely, so a failed switch cannot reintroduce + // the object that blocks the NEXT switch. var indexExists = await ScalarAsync( verifyContext, "SELECT COUNT(*) FROM sys.indexes " + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); - Assert.Equal(1, indexExists); + Assert.Equal(0, indexExists); } // ------------------------------------------------------------------------ diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs index 195b36b5..fddf5028 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs @@ -325,6 +325,57 @@ public class KpiHistoryRepositoryTests Assert.Equal(2, row.SampleCount); } + [Fact] + public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe() + { + // WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per + // (series, hour) group before writing anything — an N+1 that scaled with + // the metric catalogue times the lookback window. It now preloads the whole + // window in a single query and resolves each group from a dictionary. + // + // Six distinct series across two hours = twelve groups, so the old shape + // issued twelve SELECTs against KpiRollupHourly. The new shape issues one + // (plus the KpiSample read). This asserts the count stays small and + // constant rather than tracking the group count. + var counter = new RollupSelectCountingInterceptor(); + await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter); + var repo = new KpiHistoryRepository(ctx); + + var samples = new List(); + for (var series = 0; series < 6; series++) + { + foreach (var hourOffset in new[] { 0, 1 }) + { + samples.Add(Sample( + "NotificationOutbox", + "metric" + series, + "Global", + null, + value: series + hourOffset, + capturedAtUtc: Base.AddHours(hourOffset).AddMinutes(10))); + } + } + + await repo.RecordSamplesAsync(samples); + + // Seed the rollups so the SECOND fold takes the re-fold (update) path — + // the branch that most needed the per-group probe. + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2)); + + counter.Reset(); + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2)); + + Assert.True( + counter.RollupSelectCount <= 1, + $"expected the fold to preload existing rollups in a single query; it issued {counter.RollupSelectCount} SELECTs against KpiRollupHourly"); + + // And the fold is still correct: twelve series-hours, values unchanged by + // the re-fold. + var rollups = await ctx.KpiRollupHourly.AsNoTracking().ToListAsync(); + Assert.Equal(12, rollups.Count); + Assert.All(rollups, r => Assert.Equal(1, r.SampleCount)); + } + [Fact] public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey() { @@ -540,6 +591,42 @@ public class KpiHistoryRepositoryTests /// async non-query entry points (ExecuteDeleteAsync routes through the /// async path). /// + /// + /// Counts reader commands that SELECT from KpiRollupHourly, so a test + /// can prove the hourly fold resolves existing rows from a single preload + /// rather than one probe per (series, hour) group. + /// + private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor + { + public int RollupSelectCount { get; private set; } + + public void Reset() => RollupSelectCount = 0; + + private void CountIfRollupSelect(DbCommand command) + { + if (command.CommandText.Contains("KpiRollupHourly", StringComparison.OrdinalIgnoreCase) + && command.CommandText.Contains("SELECT", StringComparison.OrdinalIgnoreCase)) + { + RollupSelectCount++; + } + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + CountIfRollupSelect(command); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + CountIfRollupSelect(command); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + } + private sealed class DeleteCountingInterceptor : DbCommandInterceptor { public int DeleteCount { get; private set; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index 79569052..9244e8ae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -96,6 +96,47 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc); } + [SkippableFact] + public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // WP2.2 collapsed the two-round-trip "insert-if-absent then monotonic + // update" into ONE batch that updates first and inserts only when nothing + // matched. That makes @@ROWCOUNT = 0 ambiguous: it means "no such row" + // AND "the monotonic guard rejected this packet". Guarding the insert on + // @@ROWCOUNT alone would therefore let every stale/regressive packet + // append a SECOND row for an id that already exists — silently forking + // the mirror. The re-check for the row's existence is what prevents that, + // and this pins it: after a rejected regressive upsert there is still + // exactly ONE row, still carrying the advanced state. + var id = TrackedOperationId.New(); + await using var context = CreateContext(); + var repo = new SiteCallAuditRepository(context); + + await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 3, lastError: null)); + + // Every flavour of rejection: lower rank, equal terminal rank, and an + // equal non-terminal rank with a stale timestamp. + await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0)); + await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 9, lastError: "should-not-apply")); + await repo.UpsertAsync(NewRow( + id, + status: "Delivered", + retryCount: 99, + updatedAtUtc: new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(s => s.TrackedOperationId == id) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Equal("Delivered", loaded[0].Status); + Assert.Equal(3, loaded[0].RetryCount); + Assert.Null(loaded[0].LastError); + } + [SkippableFact] public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs index 661e885b..d64d3c8c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; @@ -582,4 +583,122 @@ public class SiteCallAuditReconciliationTests : TestKit Assert.Equal(siteId, evt.SiteId); Assert.True(evt.Pinned, "a legacy site that ignores after_id must publish Pinned=true"); } + + // --------------------------------------------------------------------- + // 9. WP2.2: the reconciliation drain runs OFF the mailbox, so ingest, + // query and KPI messages are answered while a long post-outage + // catch-up is still pulling. + // --------------------------------------------------------------------- + + /// + /// Pull client whose first call blocks until released, simulating a + /// post-outage catch-up that takes far longer than the caller's Ask timeout. + /// + private sealed class BlockingPullClient : IPullSiteCallsClient + { + private readonly TaskCompletionSource _release = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _entered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes once the drain has actually started pulling. + public Task Entered => _entered.Task; + + public void Release() => _release.TrySetResult(); + + public async Task PullAsync( + string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) + { + _entered.TrySetResult(); + await _release.Task.ConfigureAwait(false); + return new PullSiteCallsResponse(Array.Empty(), MoreAvailable: false); + } + } + + [Fact] + public async Task ReconciliationDrain_InFlight_DoesNotBlockIngestUpsert() + { + // The drain used to run inside ReceiveAsync, which occupies the actor for + // its whole duration. A post-outage catch-up (every site, many paged + // network pulls, one upsert per row) therefore parked telemetry ingest, + // UI queries and KPI Asks behind it — and those callers timed out rather + // than queued, so a slow site could make central look dead. This pins the + // fix: with the drain off-mailbox behind a single-flight guard, an ingest + // Ask completes promptly while the pull is still blocked. + var siteId = "siteSlow"; + var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083")); + var client = new BlockingPullClient(); + var repo = new RecordingRepo(); + + var actor = CreateActor(sites, client, repo, FastTickOptions()); + + // Wait until the drain is genuinely in flight and blocked inside PullAsync. + await client.Entered.WaitAsync(TimeSpan.FromSeconds(5)); + + // The mailbox must still be serving. A generous-but-finite budget: this + // fails at the pre-fix behaviour (the reply only arrives once the pull + // unblocks, which never happens until Release below). + var id = TrackedOperationId.New(); + var reply = await actor.Ask( + new UpsertSiteCallCommand(NewRow(id, sourceSite: siteId)), + TimeSpan.FromSeconds(3)); + + Assert.True(reply.Accepted); + Assert.Equal(id, reply.TrackedOperationId); + + // Let the drain finish so the actor shuts down cleanly. + client.Release(); + } + + [Fact] + public async Task ReconciliationTicks_DoNotOverlap_WhileADrainIsInFlight() + { + // Single-flight guard: with a 100 ms tick and a drain blocked for far + // longer, every subsequent tick must be dropped rather than starting a + // second concurrent pass — overlapping passes would race on the per-site + // cursor and pinned-latch dictionaries the drain mutates. + var siteId = "siteSlow"; + var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083")); + var client = new CountingBlockingPullClient(); + var repo = new RecordingRepo(); + + CreateActor(sites, client, repo, FastTickOptions()); + + await client.Entered.WaitAsync(TimeSpan.FromSeconds(5)); + + // Several tick intervals elapse while the first pass is still blocked. + await Task.Delay(TimeSpan.FromMilliseconds(600)); + + Assert.Equal(1, client.CallCount); + + client.Release(); + } + + /// + /// that also counts invocations, so a test + /// can prove no second pass started while the first was blocked. + /// + private sealed class CountingBlockingPullClient : IPullSiteCallsClient + { + private readonly TaskCompletionSource _release = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _entered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _callCount; + + public Task Entered => _entered.Task; + + public int CallCount => Volatile.Read(ref _callCount); + + public void Release() => _release.TrySetResult(); + + public async Task PullAsync( + string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) + { + Interlocked.Increment(ref _callCount); + _entered.TrySetResult(); + await _release.Task.ConfigureAwait(false); + return new PullSiteCallsResponse(Array.Empty(), MoreAvailable: false); + } + } }