diff --git a/docs/plans/sql/AddSiteCallsTerminalIndex.sql b/docs/plans/sql/AddSiteCallsTerminalIndex.sql new file mode 100644 index 00000000..3486a30e --- /dev/null +++ b/docs/plans/sql/AddSiteCallsTerminalIndex.sql @@ -0,0 +1,21 @@ +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260713142234_AddSiteCallsTerminalIndex' +) +BEGIN + EXEC(N'CREATE INDEX [IX_SiteCalls_Terminal] ON [SiteCalls] ([TerminalAtUtc]) WHERE [TerminalAtUtc] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260713142234_AddSiteCallsTerminalIndex' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260713142234_AddSiteCallsTerminalIndex', N'10.0.7'); +END; + +COMMIT; +GO + diff --git a/docs/requirements/Component-KpiHistory.md b/docs/requirements/Component-KpiHistory.md index 86099993..11f3b15b 100644 --- a/docs/requirements/Component-KpiHistory.md +++ b/docs/requirements/Component-KpiHistory.md @@ -88,9 +88,9 @@ A timer fires every `SampleInterval` (default 60s; an immediate first tick prime **Best-effort, per-source isolation.** Each source call and the write are individually guarded. A throwing source is logged and its samples skipped for that tick; it never aborts the tick, the other sources, or the source component itself. This is the same `IEnumerable<>`-of-adapters decoupling pattern used by `INotificationDeliveryAdapter`. -**Hourly rollup tick.** A third timer (`kpi-rollup`) fires every `RollupInterval` (default 1h). On each tick the recorder opens a per-tick DI scope and calls `IKpiHistoryRepository.FoldHourlyRollupsAsync` over the window `[TruncateToHour(now) − RollupLookbackHours, TruncateToHour(now))` — an **exclusive** upper bound at the current hour start, so the in-progress hour is never folded. The fold groups raw `KpiSample` rows by `(series, hour)`, applies the per-metric aggregation intent (sum for rate metrics, last-value for gauges — see Query), and **idempotently upserts** each `(Source, Metric, Scope, ScopeKey, HourStartUtc)` row. Re-folding the trailing lookback window (default 3h) rather than only the last hour is the self-heal for a **singleton-failover-missed tick**: because the upsert overwrites the aggregate in place, re-running the same window produces identical values (no double-count), so any complete hour a missed tick skipped is refilled on the next fold. A `_rollupInFlight` guard (mirroring `_sampleInFlight`) skips a tick while a prior fold is still running so two idempotent upserts never race on overlapping rows. +**Hourly rollup tick.** A third timer (`kpi-rollup`) fires every `RollupInterval` (default 1h). On each tick the recorder opens a per-tick DI scope and calls `IKpiHistoryRepository.FoldHourlyRollupsAsync` over the window `[TruncateToHour(now) − RollupLookbackHours, TruncateToHour(now))` — an **exclusive** upper bound at the current hour start, so the in-progress hour is never folded. The fold groups raw `KpiSample` rows by `(series, hour)`, applies the per-metric aggregation intent (sum for rate metrics, last-value for gauges — see Query), and **idempotently upserts** each `(Source, Metric, Scope, ScopeKey, HourStartUtc)` row. Re-folding the trailing lookback window (default 3h) rather than only the last hour is the self-heal for a **singleton-failover-missed tick**: because the upsert overwrites the aggregate in place, re-running the same window produces identical values (no double-count), so any complete hour a missed tick skipped is refilled on the next fold. A `_rollupInFlight` guard (mirroring `_sampleInFlight`) skips a tick while a prior fold is still running so two idempotent upserts never race on overlapping rows. During a **failover overlap** — the old singleton incarnation's in-flight fold and the new node's first fold both `Add`-ing the same `(series, hour)` row — the loser of the unique `IX_KpiRollupHourly_Series` upsert race discards its **whole fold pass** (a single `SaveChanges`), logged at Information and repaired by the next idempotent fold; when reading fold logs after a failover, the failure grain is the **pass, not the row** (arch-review 04 round 2, R5). -**One-shot backfill.** So the 30 d / 90 d charts are not blank until enough wall-clock passes, the recorder runs a **single** backfill on start (`kpi-rollup-backfill` timer) that folds the full retention window once, `[TruncateToHour(now) − RetentionDays, TruncateToHour(now))`, reusing the same `FoldHourlyRollupsAsync` path. A `_backfillDone` latch makes it at-most-once per actor lifetime, and it defers (re-arms) while a periodic fold is in flight; being an idempotent upsert, it is cheap and safe to re-run on a later failover restart. +**One-shot backfill.** So the 30 d / 90 d charts are not blank until enough wall-clock passes, the recorder runs a backfill on start (`kpi-rollup-backfill` timer) that folds the retention window, reusing the same `FoldHourlyRollupsAsync` path. The `FoldHourlyRollupsAsync` repository call materializes its whole window in memory, so the backfill folds the window in **≤24 h slices, oldest-first** — one bounded slice per fold — rather than handing the fold the full 90-day window at once (which at fleet volume is tens of millions of rows in one tracked pass). Between slices it lowers its in-flight guard so periodic folds **interleave** instead of stalling behind the historical pass; a periodic fold and a backfill slice strictly alternate through the single `_rollupInFlight`/`_backfillInFlight` gate pair, so the two idempotent upserts never race on overlapping rows. On a **failover restart** the plan first consults the `IKpiHistoryRepository.GetLatestRollupHourAsync` watermark: if the newest folded hour is already within `RollupLookbackHours` of now (the common case) the whole historical pass is **skipped** — the periodic fold's lookback covers the tail — otherwise the window is **shrunk to the un-rolled tail** `[watermark, TruncateToHour(now))` (re-folding the watermark hour itself as an idempotent safety margin) instead of the full `RetentionDays` floor. A `_backfillDone` latch makes the whole pass at-most-once per actor lifetime, and it defers (re-arms) while a periodic fold is in flight. Being an idempotent upsert, it is safe (idempotent) to re-run on a later failover restart; the watermark fast-path makes the failover re-run cheap as well. **Retention.** A daily purge timer (`PurgeInterval`, default 24h) now runs **both** purges: raw `KpiSample` rows older than `RetentionDays` (default 90) via `IKpiHistoryRepository.PurgeOlderThanAsync`, **and** hourly rollup rows older than `RollupRetentionDays` (default 365) via `IKpiHistoryRepository.PurgeRollupsOlderThanAsync` (a one-hour-sliced batched DELETE mirroring the raw purge). Rollups deliberately **outlive** raw samples so long-range trend charts have data after the raw rows are purged; the options validator enforces `RollupRetentionDays >= RetentionDays` so a window never falls into a hole where raw is purged but no rollup exists. @@ -148,15 +148,17 @@ Reads `ICentralHealthAggregator.GetAllSiteStates()` (in-memory, no DB). Scope: * ### Bucketed query -`IKpiHistoryRepository.GetRawSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, …)` returns the raw points for one series over `[fromUtc, toUtc]`. `GetHourlySeriesAsync(…)` returns the same ascending `KpiSeriesPoint` shape from `KpiRollupHourly` (projecting `HourStartUtc` → the point timestamp, `Value`), so the bucketer is agnostic to the source. `KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, maxPoints)` then partitions the window into ≤ `maxPoints` time buckets and returns the **last value per bucket** as `KpiSeriesPoint(BucketStartUtc, Value)` — unchanged, and still applied on top of a rollup series (a 90 d rollup is ~2,160 points > the 200-point cap, so bucketing still thins it). The chart plots the single `Value` series; avg / min / max charting is still deferred, though the rollup's `MinValue` / `MaxValue` / `SampleCount` columns are now stored to unblock it. +`IKpiHistoryRepository.GetRawSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, …)` returns the raw points for one series over `[fromUtc, toUtc]`. `GetHourlySeriesAsync(…)` returns the same ascending `KpiSeriesPoint` shape from `KpiRollupHourly` (projecting `HourStartUtc` → the point timestamp, `Value`), so the bucketer is agnostic to the source. `KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, maxPoints, aggregation)` then partitions the window into ≤ `maxPoints` time buckets and reduces each bucket **per-metric**: it keeps the **last value** for a **Gauge** series and **sums per bucket** for a **Rate** series, driven by the same `KpiMetricAggregationCatalog` the hourly fold consults. The previous unconditional last-value-per-bucket kept **1 of ~11 hourly sums** per 90 d bucket on the rollup path — the exact fold error the catalog's own doc warns about, re-introduced one layer up — and is fixed per arch-review 04 round 2, R3. The reduction is still applied on top of a rollup series (a 90 d rollup is ~2,160 points > the 200-point cap, so bucketing still thins it). The chart plots the single `Value` series; avg / min / max charting is still deferred, though the rollup's `MinValue` / `MaxValue` / `SampleCount` columns are now stored to unblock it. ### Raw-vs-rollup range routing `GetRawSeriesAsync` and `GetHourlySeriesAsync` are selected by window width in the query service (below), governed by `RollupThresholdHours` (default 168 = 7 d). Windows **at or below** the threshold read raw samples (preserving intra-minute detail on the 24 h / 7 d charts); windows **strictly wider** than the threshold read the pre-aggregated hourly rollups (≈60× fewer rows scanned on the 30 d / 90 d charts). The boundary is strict — a window of exactly `RollupThresholdHours` stays on the raw path. +Rate charts keep **one unit (events per chart bucket)** on both sides of the boundary: the catalog-driven per-bucket sum reduction is applied to the raw-minute deltas and the hourly rollup sums alike, so widening 7 d → 30 d no longer changes a Rate chart's magnitude ~60×. Residual: an unbucketed just-over-threshold rollup series plots native per-hour sums vs. the at-threshold raw path's ~50-minute bucket sums (≤~1.2×) — accepted and documented, not hidden. + ### Aggregation intent — gauge vs. rate -Not every metric folds the same way, and choosing wrong silently corrupts long-range totals. `KpiMetricAggregationCatalog.Resolve(source, metric)` classifies each `(Source, Metric)` pair as `KpiRollupAggregation.Gauge` (fold = **last-value-per-hour**) or `KpiRollupAggregation.Rate` (fold = **sum-per-hour**): +Not every metric folds the same way, and choosing wrong silently corrupts long-range totals. `KpiMetricAggregationCatalog.Resolve(source, metric)` classifies each `(Source, Metric)` pair as `KpiRollupAggregation.Gauge` (fold = **last-value-per-hour**) or `KpiRollupAggregation.Rate` (fold = **sum-per-hour**). The catalog now has **two consumers**: the hourly rollup fold (which picks last-value vs. sum per hour) and the chart-time bucket reduction in `KpiSeriesBucketer` (which picks last-value vs. sum per chart bucket) — one classification keeps both layers consistent: - **Gauge** (last-value) — instantaneous readings where a per-hour sum is meaningless: `queueDepth`, `buffered`, `stuck`/`stuckCount`, `parked*`, `oldestPendingAgeSeconds`, `connectionsUp`/`connectionsDown`, `deployedInstances`, and every other metric not explicitly listed as a rate. This is the **safe default**: an unknown / newly-added metric resolves to Gauge and never crashes the fold or invents a total. - **Rate** (sum-per-hour) — already-windowed per-interval deltas whose hour total is the sum of its minute samples: `NotificationOutbox/deliveredLastInterval`, `SiteCallAudit/failedLastInterval`, `SiteCallAudit/deliveredLastInterval`, and the per-report `SiteHealth` error counts `scriptErrors` / `alarmEvalErrors` / `deadLetters` / `eventLogWriteFailures`. Folding these as last-value would keep one minute's delta and discard the other 59, under-counting every long-range total. diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs index becaec0c..82014323 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs @@ -61,32 +61,42 @@ public sealed class SiteAuditRetentionService : IHostedService, IDisposable private async Task RunLoopAsync(CancellationToken ct) { - // InitialDelay before the first purge — short so a daily-recycled node - // still purges soon after start rather than waiting a full interval it may - // never reach. try { - await Task.Delay(_options.InitialDelay, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } - - await SafePurgeAsync(ct).ConfigureAwait(false); - - while (!ct.IsCancellationRequested) - { + // InitialDelay before the first purge — short so a daily-recycled node + // still purges soon after start rather than waiting a full interval it may + // never reach. try { - await Task.Delay(_options.ResolvedPurgeInterval, ct).ConfigureAwait(false); + await Task.Delay(_options.InitialDelay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { - break; + return; } await SafePurgeAsync(ct).ConfigureAwait(false); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(_options.ResolvedPurgeInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + await SafePurgeAsync(ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutdown landed mid-purge: SafePurgeAsync rethrows OCE by design so the purge + // aborts promptly, but the loop task must complete CLEANLY — StopAsync hands + // _loop straight to the host, and a canceled task there is shutdown-log noise + // (arch-review 04 round 2, R7). Cancellation here IS the clean exit. } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs index e6b6be28..358fee33 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs @@ -51,6 +51,14 @@ public partial class KpiTrendChart /// The series to plot, assumed ordered ascending by /// . null or empty renders /// the unavailable placeholder rather than an empty chart. + /// + /// The per-point value's meaning depends on the metric's + /// + /// classification: a Rate-classified series carries per-bucket totals (the + /// bucketer sums each bucket's deltas), while a Gauge series carries + /// last-value-per-bucket readings. The chart is agnostic to which — it plots the + /// supplied values verbatim (arch-review 04 round 2, R3). + /// /// [Parameter] public IReadOnlyList? Points { get; set; } @@ -58,7 +66,10 @@ public partial class KpiTrendChart /// data-test slug. Required-ish; defaults to "Trend" if blank. [Parameter] public string Title { get; set; } = "Trend"; - /// Optional unit suffix appended to value labels (e.g. "s"). + /// Optional unit suffix appended to value labels (e.g. "s"). For a + /// Rate-classified metric the supplied text must state the per-bucket-total + /// semantics (e.g. "delivered/bucket"), not a per-minute/per-interval reading, + /// so the label matches the summed values in (R3). [Parameter] public string? Unit { get; set; } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs index 3aee78f3..e0070b92 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs @@ -76,12 +76,18 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService { var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints; + // Per-metric reduction intent: the same catalog that drives the hourly fold drives + // the chart-time bucket reduction, so a Rate series is summed per bucket on BOTH the + // raw-minute and hourly-rollup routes — one unit ("events per chart bucket") on both + // sides of the RollupThresholdHours routing boundary (arch-review 04 round 2, R3). + var aggregation = KpiMetricAggregationCatalog.Resolve(source, metric); + // Test-seam ctor: use the injected repository directly. if (_injectedRepository is not null) { var injectedSeries = await FetchSeriesAsync( _injectedRepository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken); - return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax); + return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax, aggregation); } // Production: a fresh scope (and thus a fresh DbContext) per query so a @@ -90,7 +96,7 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService var repository = serviceScope.ServiceProvider.GetRequiredService(); var series = await FetchSeriesAsync( repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken); - return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax); + return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax, aggregation); } /// @@ -103,6 +109,13 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService /// the caller's step is agnostic to the source /// (a 90 d rollup can still exceed the point ceiling, so bucketing still applies). /// The boundary is strict: exactly RollupThresholdHours stays on the raw path. + /// + /// The routing boundary no longer changes a Rate chart's magnitude: both paths reduce to + /// per-bucket sums via the catalog-driven aggregation applied in + /// . A residual ≤~1.2× native-resolution difference exists only + /// for an unbucketed just-over-threshold rollup series (plotted at native per-hour sums) vs. + /// an at-threshold bucketed raw series — documented, not hidden (arch-review 04 round 2, R3). + /// /// private Task> FetchSeriesAsync( IKpiHistoryRepository repository, diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs index 1ebb8a78..774f9a11 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs @@ -109,6 +109,16 @@ public interface IKpiHistoryRepository string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + /// + /// Returns the newest KpiRollupHourly.HourStartUtc across all series, or + /// null when no rollups exist. The recorder's one-shot backfill consults + /// this watermark to skip (or shrink to) the un-rolled tail instead of re-folding + /// the whole raw-retention window on every failover (arch-review 04 round 2, R1). + /// + /// Cancellation token. + /// A task resolving to the newest folded hour start, or null when empty. + Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default); + /// /// Bulk-deletes hourly rollup rows whose /// is diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs index df661750..a463a875 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetricAggregation.cs @@ -42,10 +42,10 @@ public enum KpiRollupAggregation /// Keying by (source, metric) — not metric alone — is deliberate: /// deliveredLastInterval is emitted by both /// and , -/// so a metric name is not globally unique. Every metric string is taken from the -/// emitting source verbatim (shared constants where they exist, -/// otherwise the source's own private literal), so this catalog stays a faithful mirror -/// of what actually lands in KpiSample.Metric. +/// so a metric name is not globally unique. Every catalogued metric string is now a shared +/// constant — the emitting source consumes the same constant — so an +/// emitter rename is a compile error, not a silent Gauge downgrade (R4), and +/// this catalog stays a faithful mirror of what actually lands in KpiSample.Metric. /// /// /// The catalog stores only the pairs; anything @@ -57,12 +57,6 @@ public enum KpiRollupAggregation /// public static class KpiMetricAggregationCatalog { - // Metric literals for the sources that keep their names private (no shared - // KpiMetrics constant exists for these). Charted metrics use the KpiMetrics catalog. - private const string SiteCallAuditDeliveredLastInterval = "deliveredLastInterval"; - private const string SiteHealthAlarmEvalErrors = "alarmEvalErrors"; - private const string SiteHealthEventLogWriteFailures = "eventLogWriteFailures"; - /// /// The (source, metric) pairs classified as /// (sum-per-hour). Every other emitted metric is a . @@ -99,11 +93,11 @@ public static class KpiMetricAggregationCatalog { (KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.DeliveredLastInterval), (KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval), - (KpiSources.SiteCallAudit, SiteCallAuditDeliveredLastInterval), + (KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.DeliveredLastInterval), (KpiSources.SiteHealth, KpiMetrics.SiteHealth.ScriptErrors), - (KpiSources.SiteHealth, SiteHealthAlarmEvalErrors), + (KpiSources.SiteHealth, KpiMetrics.SiteHealth.AlarmEvalErrors), (KpiSources.SiteHealth, KpiMetrics.SiteHealth.DeadLetters), - (KpiSources.SiteHealth, SiteHealthEventLogWriteFailures), + (KpiSources.SiteHealth, KpiMetrics.SiteHealth.EventLogWriteFailures), }; /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs index d2230812..ffedf771 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs @@ -18,11 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; /// not a rename — changing any value would orphan historical samples. /// /// -/// Only the metrics a trend page actually charts are catalogued here. Sources may emit -/// additional internal metrics (e.g. the Notification Outbox stuckCount / -/// oldestPendingAgeSeconds, the Site Call Audit deliveredLastInterval / -/// stuck / oldestPendingAgeSeconds, and the bulk of the Site Health catalog) -/// that no page references; those stay private to their source until a page charts them. +/// This catalog now carries two kinds of metric: those a trend page charts, and +/// those the classifies for the hourly rollup fold +/// (e.g. SiteCallAudit/deliveredLastInterval, SiteHealth/alarmEvalErrors, +/// SiteHealth/eventLogWriteFailures) even if no page charts them yet. The C4/R4 lesson +/// is that any metric named in two places needs one shared symbol: a private +/// literal on the emitter plus a matching private literal in the catalog drifts silently on a +/// rename. Sources may still keep genuinely single-use internal metrics (e.g. the Notification +/// Outbox stuckCount / oldestPendingAgeSeconds, the Site Call Audit stuck / +/// oldestPendingAgeSeconds, and the bulk of the Site Health catalog) private until a page +/// or the catalog references them. /// Constants are grouped by source via nested static classes mirroring /// . /// @@ -65,6 +70,11 @@ public static class KpiMetrics /// Cached calls that failed permanently in the last KPI interval. public const string FailedLastInterval = "failedLastInterval"; + + /// Cached calls delivered in the last KPI interval. Not charted by a trend + /// page today, but named in the rollup aggregation catalog as a Rate metric — so it + /// is a shared symbol to keep the emitter and catalog in lock-step (R4). + public const string DeliveredLastInterval = "deliveredLastInterval"; } /// @@ -98,6 +108,16 @@ public static class KpiMetrics /// Script-execution error count reported by the site. public const string ScriptErrors = "scriptErrors"; + /// Alarm-evaluation error count reported by the site. Named in the rollup + /// aggregation catalog as a Rate metric — shared symbol so an emitter rename is a + /// compile error, not a silent Gauge downgrade (R4). + public const string AlarmEvalErrors = "alarmEvalErrors"; + + /// Event-log write-failure count reported by the site. Named in the rollup + /// aggregation catalog as a Rate metric — shared symbol so an emitter rename is a + /// compile error, not a silent Gauge downgrade (R4). + public const string EventLogWriteFailures = "eventLogWriteFailures"; + /// Summed store-and-forward buffer depth across all buffers. public const string SfBufferDepth = "sfBufferDepth"; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs index 8948f1b2..9bfc45e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs @@ -3,8 +3,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; /// /// Pure, deterministic downsampling helper for KPI series charting ("KPI History & Trends"). /// Reduces a raw series to at most maxPoints points using -/// last-value-per-bucket / gauge semantics — suitable for step/area charts where the most -/// recent value in a window best represents that window. +/// per-metric bucket reduction driven by : +/// (the default) keeps the last value +/// in each bucket — suitable for step/area charts where the most recent reading best represents +/// the window; sums per bucket — each +/// raw point of a Rate series is a per-interval delta, so the bucket's true total is the sum of +/// its deltas. Summing on both the raw-minute and hourly-rollup paths charts the same unit +/// ("events per chart bucket") on both sides of the raw/rollup routing boundary, eliminating the +/// ~60× magnitude jump at that boundary (arch-review 04 round 2, R3). /// public static class KpiSeriesBucketer { @@ -27,12 +33,20 @@ public static class KpiSeriesBucketer /// UTC start of the query window (inclusive). /// UTC end of the query window (inclusive on the right edge). /// Maximum number of output points. Must be ≥ 2. + /// + /// How each bucket is reduced. (default) keeps the + /// last value in the bucket — the historical behavior. + /// sums the values in the bucket — each raw point of a Rate series is a per-interval delta, so + /// the bucket total is the sum of its deltas (arch-review 04 round 2, R3). + /// /// /// An of at most bucketed points, /// ordered by ascending. - /// Returns unchanged (same reference) when - /// raw.Count <= maxPoints; callers must not mutate the underlying - /// collection in that case, as it is the same object passed in. + /// For returns unchanged + /// (same reference) when raw.Count <= maxPoints; callers must not mutate the + /// underlying collection in that case. always runs + /// the bucketing pass — two deltas landing in one bucket must sum even in a short series — + /// so it never returns the input reference. /// /// /// Thrown when < 2 or @@ -44,7 +58,8 @@ public static class KpiSeriesBucketer IReadOnlyList raw, DateTime fromUtc, DateTime toUtc, - int maxPoints) + int maxPoints, + KpiRollupAggregation aggregation = KpiRollupAggregation.Gauge) { if (maxPoints < 2) throw new ArgumentOutOfRangeException(nameof(maxPoints), @@ -54,11 +69,15 @@ public static class KpiSeriesBucketer throw new ArgumentOutOfRangeException(nameof(toUtc), toUtc, "toUtc must be strictly greater than fromUtc."); - // Normal runtime case — empty or short series: return as-is. + // Normal runtime case — empty series: return as-is. if (raw is null || raw.Count == 0) return Array.Empty(); - if (raw.Count <= maxPoints) + // Short-series early return is Gauge-only: for Rate, two deltas landing in one bucket + // must still sum, so a short Rate series cannot be returned verbatim (arch-review 04 + // round 2, R3). For well-spaced short Rate series the per-point buckets make the sum an + // identity, so the output still equals the input anyway. + if (aggregation == KpiRollupAggregation.Gauge && raw.Count <= maxPoints) return raw; // Divide the window into maxPoints equal-width buckets. @@ -67,12 +86,11 @@ public static class KpiSeriesBucketer double windowTicks = (double)(toUtc.Ticks - fromUtc.Ticks); double bucketWidthTicks = windowTicks / maxPoints; - // For each bucket, track the candidate point: the one with the - // maximum BucketStartUtc (last value within the bucket). - // We use a fixed-size array indexed by bucket number. - // Nullable KpiSeriesPoint[] with 'hasValue' flags is fine since the - // struct is small. + // For each bucket, track the candidate point: for Gauge the one with the maximum + // BucketStartUtc (last value within the bucket); for Rate a running sum of the bucket's + // deltas. Fixed-size arrays indexed by bucket number. var best = new KpiSeriesPoint[maxPoints]; + var sums = new double[maxPoints]; var occupied = new bool[maxPoints]; foreach (var point in raw) @@ -89,13 +107,19 @@ public static class KpiSeriesBucketer if (bucketIndex >= maxPoints) bucketIndex = maxPoints - 1; + if (aggregation == KpiRollupAggregation.Rate) + { + // Rate: accumulate the bucket's per-interval deltas. + sums[bucketIndex] += point.Value; + occupied[bucketIndex] = true; + } // Keep the last point in iteration order within this bucket. Because the stored // candidate's BucketStartUtc is the bucket-START timestamp (not the raw point's // capture time), the comparison below is true for essentially any in-bucket point, // so each later-in-iteration point overwrites the previous one. For the ascending- // sorted input this method requires, last-in-iteration IS the largest-timestamp // point — i.e. last-value-per-bucket semantics. - if (!occupied[bucketIndex] || + else if (!occupied[bucketIndex] || point.BucketStartUtc > best[bucketIndex].BucketStartUtc) { best[bucketIndex] = new KpiSeriesPoint( @@ -109,8 +133,13 @@ public static class KpiSeriesBucketer var result = new List(maxPoints); for (int i = 0; i < maxPoints; i++) { - if (occupied[i]) - result.Add(best[i]); + if (!occupied[i]) + continue; + + result.Add(aggregation == KpiRollupAggregation.Rate + ? new KpiSeriesPoint( + fromUtc + TimeSpan.FromTicks((long)(i * bucketWidthTicks)), sums[i]) + : best[i]); } return result; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs index 74a49d41..4a638c06 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -97,5 +97,14 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration s.TerminalAtUtc) + .HasDatabaseName("IX_SiteCalls_Terminal") + .HasFilter("[TerminalAtUtc] IS NOT NULL"); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260713142234_AddSiteCallsTerminalIndex.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260713142234_AddSiteCallsTerminalIndex.Designer.cs new file mode 100644 index 00000000..52cf123e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260713142234_AddSiteCallsTerminalIndex.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("20260713142234_AddSiteCallsTerminalIndex")] + partial class AddSiteCallsTerminalIndex + { + /// + 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.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("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + 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/20260713142234_AddSiteCallsTerminalIndex.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260713142234_AddSiteCallsTerminalIndex.cs new file mode 100644 index 00000000..e7be6661 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260713142234_AddSiteCallsTerminalIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class AddSiteCallsTerminalIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SiteCalls_Terminal", + table: "SiteCalls", + column: "TerminalAtUtc", + filter: "[TerminalAtUtc] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SiteCalls_Terminal", + table: "SiteCalls"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 75bca65e..35bfe2ac 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -167,6 +167,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations 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"); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs index d772b543..b1298cfb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; @@ -13,14 +15,22 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; public sealed class KpiHistoryRepository : IKpiHistoryRepository { private readonly ScadaBridgeDbContext _context; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The EF Core database context. - public KpiHistoryRepository(ScadaBridgeDbContext context) + /// + /// Optional logger; defaults to (mirrors + /// SiteCallAuditRepository — MS.DI resolves automatically, + /// so no registration churn). Used to classify the self-healing failover fold-race. + /// + public KpiHistoryRepository( + ScadaBridgeDbContext context, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? NullLogger.Instance; } /// @@ -101,8 +111,14 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository // fetch it and group IN MEMORY. This deliberately avoids provider-specific // DATEADD/DATEPART hour-truncation translation (SQLite in tests, SQL Server in prod), // keeping the hour-bucket arithmetic identical across providers and trivially correct. + // Projected, untracked fetch: the fold only reads these six fields and never + // mutates a KpiSample. A tracked ToListAsync registered ~5k-90k read-only + // entities in the change tracker per healthy 3h fold — all re-scanned by + // DetectChanges on the final SaveChanges (arch-review 04 round 2, R2). A + // projection is inherently untracked and materializes no entity at all. var samples = await _context.KpiSamples .Where(s => s.CapturedAtUtc >= from && s.CapturedAtUtc < to) + .Select(s => new FoldSample(s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc, s.Value)) .ToListAsync(cancellationToken); if (samples.Count == 0) { @@ -164,7 +180,22 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository } } - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + // Failover-overlap race: the old singleton incarnation's in-flight fold and the + // new node's first fold can both Add the same (series, hour) row; the unique + // IX_KpiRollupHourly_Series then faults the loser's ENTIRE SaveChanges — the + // failure grain is the PASS, not the row (arch-review 04 round 2, R5). The fold + // only ever writes KpiRollupHourly rows, so any save fault here is a lost upsert + // race or a transient the next idempotent re-fold repairs identically; classify + // at Information instead of surfacing a scary error for a self-healing no-op. + _logger.LogInformation(ex, + "KPI rollup fold lost a failover-overlap upsert race; pass discarded, next fold self-heals."); + } } /// @@ -186,6 +217,15 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository .ToListAsync(cancellationToken); } + /// + public async Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) + { + // Single MAX over the unique IX_KpiRollupHourly_Series population (the rollup + // table is small — one row per series-hour). Null when no rollups exist. + return await _context.KpiRollupHourly + .MaxAsync(r => (DateTime?)r.HourStartUtc, cancellationToken); + } + /// public async Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) { @@ -212,4 +252,8 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository /// In-memory grouping key: one KPI series (four-tuple) within one UTC hour. private readonly record struct SeriesHourKey( string Source, string Metric, string Scope, string? ScopeKey, DateTime HourStartUtc); + + /// Narrow, untracked projection of one KpiSample row for the fold (R2). + private readonly record struct FoldSample( + string Source, string Metric, string Scope, string? ScopeKey, DateTime CapturedAtUtc, double Value); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index 075b3626..29137a77 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -246,9 +246,29 @@ OPTION (RECOMPILE);"; /// public async Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { - return await _context.Database.ExecuteSqlInterpolatedAsync( - $"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};", - ct); + // Time-sliced batches (arch-review 04 round 2, R6 — the one maintenance DELETE + // that missed round 1's batching pass): each DELETE covers at most one DAY of + // terminal rows, capping the lock/log footprint per statement. Steady state + // (daily purge, 365-day retention) is a single slice; only catch-up after an + // outage runs several. One-day (not one-hour) slices are proportionate to + // SiteCalls volume, which is far below KpiSample's. The MIN() anchor and the + // DELETE predicate both seek IX_SiteCalls_Terminal (filtered IS NOT NULL). + var total = 0; + var floor = await _context.SiteCalls + .Where(s => s.TerminalAtUtc != null && s.TerminalAtUtc < olderThanUtc) + .MinAsync(s => s.TerminalAtUtc, ct); + while (floor is not null && floor < olderThanUtc) + { + var ceiling = floor.Value.AddDays(1) < olderThanUtc ? floor.Value.AddDays(1) : olderThanUtc; + total += await _context.Database.ExecuteSqlInterpolatedAsync( + $"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {ceiling};", + ct); + floor = await _context.SiteCalls + .Where(s => s.TerminalAtUtc != null && s.TerminalAtUtc < olderThanUtc) + .MinAsync(s => s.TerminalAtUtc, ct); + } + + return total; } // Terminal status string literals for the interval-throughput KPIs. The diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs index 1c2701ca..501cb967 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs @@ -40,7 +40,7 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource private const string MetricConnectionsUp = "connectionsUp"; private const string MetricConnectionsDown = KpiMetrics.SiteHealth.ConnectionsDown; private const string MetricScriptErrors = KpiMetrics.SiteHealth.ScriptErrors; - private const string MetricAlarmEvalErrors = "alarmEvalErrors"; + private const string MetricAlarmEvalErrors = KpiMetrics.SiteHealth.AlarmEvalErrors; private const string MetricSfBufferDepth = KpiMetrics.SiteHealth.SfBufferDepth; private const string MetricDeadLetters = KpiMetrics.SiteHealth.DeadLetters; private const string MetricParkedMessages = "parkedMessages"; @@ -48,7 +48,7 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource private const string MetricEnabledInstances = "enabledInstances"; private const string MetricDisabledInstances = "disabledInstances"; private const string MetricAuditBacklogPending = "auditBacklogPending"; - private const string MetricEventLogWriteFailures = "eventLogWriteFailures"; + private const string MetricEventLogWriteFailures = KpiMetrics.SiteHealth.EventLogWriteFailures; private readonly ICentralHealthAggregator _aggregator; diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs index 4ac9c74c..1624b55a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs @@ -31,7 +31,12 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory; /// series. A one-shot backfill timer additionally folds the whole raw-retention /// window of already-recorded samples once shortly after start, so 30 d/90 d /// charts aren't blank until enough wall-clock passes after deploy; it reuses the -/// same idempotent fold and runs at most once per actor lifetime. +/// same idempotent fold and runs at most once per actor lifetime. The backfill is +/// sliced into bounded 24 h fold windows (oldest-first) rather than one unbounded +/// pass, and lowers its in-flight guard between slices so periodic folds interleave +/// — periodic folds and backfill slices strictly alternate through the single +/// _rollupInFlight/_backfillInFlight gate pair, so the two idempotent +/// upserts never race on overlapping rows (arch-review 04 round 2, R1). /// /// /// @@ -118,11 +123,32 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers private bool _rollupInFlight; /// - /// In-flight guard for the one-shot backfill fold. Set true when the backfill fold is - /// launched and cleared when its arrives. While true, periodic - /// s are skipped (like ) so the (possibly - /// long) full-retention-window backfill never runs concurrently with a periodic lookback fold - /// on the same upsert rows. + /// Width of one backfill fold slice (24 h). The repository fold materializes its whole + /// window in memory, so the one-shot backfill must never hand it the full raw-retention + /// window (90 d ≈ tens of millions of rows at fleet volume — arch-review 04 round 2, R1). + /// One day per fold bounds each pass to roughly what a steady-state day writes, and the + /// guard is lowered between slices so periodic folds interleave instead of stalling + /// behind the historical pass. + /// + private static readonly TimeSpan BackfillSliceWidth = TimeSpan.FromHours(24); + + /// + /// Queue of remaining backfill fold slices (each a bounded + /// window), oldest-first. Built once when the backfill plan is enumerated and drained one + /// slice per fold; a periodic may claim the fold path between slices + /// (the two idempotent upserts strictly alternate through the + /// / gate pair, never racing on + /// overlapping rows — arch-review 04 round 2, R1). + /// + private readonly Queue<(DateTime FromHourUtc, DateTime ToHourUtc)> _backfillSlices = new(); + + /// + /// In-flight guard for a single backfill fold slice. Set true when a slice fold is launched + /// and cleared when its arrives. While true, periodic + /// s are skipped (like ) so a backfill + /// slice never runs concurrently with a periodic lookback fold on the same upsert rows; it is + /// lowered BETWEEN slices so periodic folds interleave instead of stalling behind the whole + /// historical pass. /// private bool _backfillInFlight; @@ -160,10 +186,21 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers Receive(_ => HandleRollupTick()); Receive(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault) Receive(_ => HandleBackfillTick()); - Receive(_ => + Receive(HandleBackfillPlan); + Receive(_ => { - _backfillInFlight = false; // lower the in-flight guard (success or fault) - _backfillDone = true; // latch: at most once per actor lifetime + _backfillInFlight = false; // release the fold path BETWEEN slices (R1) + if (_backfillSlices.Count == 0) + { + _backfillDone = true; // latch: at most once per actor lifetime (unchanged semantics) + _logger.LogInformation("KPI rollup backfill completed — all slices folded."); + return; + } + + // Next slice via the timer, not inline: a RollupTick already queued in the mailbox + // is processed first, so periodic folds interleave; the existing _rollupInFlight + // deferral branch in HandleBackfillTick then re-arms the backfill behind it. + Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero); }); } @@ -483,13 +520,15 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers /// /// Handles the one-shot backfill tick. Runs at most once per actor lifetime: if the backfill - /// has already completed () or is in flight + /// has already completed () or a slice is in flight /// () the tick is ignored. If a periodic fold currently holds /// the fold path () the backfill defers by re-arming its single - /// timer, so the full-window backfill and the periodic lookback fold never race on overlapping - /// upsert rows. Otherwise it raises the backfill guard and folds the whole raw-retention - /// window [TruncateToHour(now) − RetentionDays, TruncateToHour(now)) off the actor - /// thread, piping a back to lower the guard and set the latch. + /// timer, so a backfill slice and the periodic lookback fold never race on overlapping upsert + /// rows. When no slice plan exists yet it enumerates the raw-retention window + /// [TruncateToHour(now) − RetentionDays, TruncateToHour(now)) into bounded + /// windows (oldest-first) and self-arms to start draining; + /// otherwise it dequeues ONE slice, raises the guard, and folds it off the actor thread, + /// piping a back to release the guard between slices. /// private void HandleBackfillTick() { @@ -500,38 +539,152 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers if (_rollupInFlight) { - // A periodic fold is in flight; defer the one-shot backfill briefly so the two + // A periodic fold is in flight; defer the next backfill slice briefly so the two // idempotent upserts don't contend on overlapping rows. Re-arm the single timer. Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.FromSeconds(5)); return; } + if (_backfillSlices.Count == 0) + { + // No slice plan yet: resolve the rollup watermark off the actor thread, then plan on + // the actor thread (Receive). Raise the guard so periodic ticks defer + // while planning is in flight; the plan handler lowers it. On a failover restart the + // watermark lets the plan SKIP or SHRINK to the un-rolled tail instead of re-folding + // the whole raw-retention window (arch-review 04 round 2, R1). + _backfillInFlight = true; + var planCancellationToken = _shutdownCts?.Token ?? CancellationToken.None; + + RunBackfillPlanPass(planCancellationToken).PipeTo( + Self, + success: watermark => new BackfillPlan(watermark), + failure: ex => + { + _logger.LogError(ex, "KPI rollup backfill plan pass faulted unexpectedly."); + return new BackfillPlan(null); // null watermark ⇒ plan the full retention window + }); + return; + } + + // Dequeue and fold exactly ONE bounded slice. + var slice = _backfillSlices.Dequeue(); _backfillInFlight = true; - var toHourUtc = TruncateToHour(DateTime.UtcNow); - var fromHourUtc = toHourUtc - TimeSpan.FromDays(_options.RetentionDays); var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None; - _logger.LogInformation( - "KPI rollup backfill starting — folding existing samples in [{FromHour:o}, {ToHour:o}) ({RetentionDays}-day window).", - fromHourUtc, toHourUtc, _options.RetentionDays); - - RunBackfillPass(fromHourUtc, toHourUtc, cancellationToken).PipeTo( + RunBackfillPass(slice.FromHourUtc, slice.ToHourUtc, cancellationToken).PipeTo( Self, - success: () => BackfillComplete.Instance, + success: () => BackfillSliceComplete.Instance, failure: ex => { - _logger.LogError(ex, "KPI rollup backfill faulted unexpectedly."); - return BackfillComplete.Instance; + _logger.LogError(ex, "KPI rollup backfill slice faulted unexpectedly."); + return BackfillSliceComplete.Instance; }); } /// - /// Runs the one-shot backfill fold: opens a DI scope, resolves the repository, and folds the + /// Enumerates [, ) into + /// contiguous windows (the last possibly shorter), + /// oldest-first, and enqueues them onto . + /// + private void EnqueueBackfillSlices(DateTime fromHourUtc, DateTime toHourUtc) + { + var sliceStart = fromHourUtc; + while (sliceStart < toHourUtc) + { + var sliceEnd = sliceStart + BackfillSliceWidth; + if (sliceEnd > toHourUtc) + { + sliceEnd = toHourUtc; + } + + _backfillSlices.Enqueue((sliceStart, sliceEnd)); + sliceStart = sliceEnd; + } + } + + /// + /// Plans the backfill from the resolved rollup watermark (actor thread). The window floor is + /// the newer of the watermark (re-folding the watermark hour itself is a cheap idempotent + /// safety margin) and the raw-retention floor; a null watermark (fresh install) keeps the full + /// retention window. If the floor is already within + /// of now the periodic fold's lookback + /// covers everything from there forward, so the whole historical pass is skipped (the failover + /// fast-path); otherwise the tail [floor, TruncateToHour(now)) is enumerated into + /// bounded slices and draining is self-armed. Either way the plan-pass guard is lowered here + /// (arch-review 04 round 2, R1). + /// + private void HandleBackfillPlan(BackfillPlan plan) + { + var toHourUtc = TruncateToHour(DateTime.UtcNow); + var retentionFloor = toHourUtc - TimeSpan.FromDays(_options.RetentionDays); + // Shrink to the un-rolled tail; re-fold the watermark hour itself (idempotent) as a safety + // margin. A null watermark (fresh install) keeps the full retention window. + var floor = plan.Watermark is { } w && w > retentionFloor ? w : retentionFloor; + + if (floor >= toHourUtc - TimeSpan.FromHours(_options.RollupLookbackHours)) + { + // Failover fast-path: rollups are already current — the periodic fold's lookback + // window covers everything from the watermark forward, so the full historical pass is + // skipped entirely (arch-review 04 round 2, R1). + _backfillInFlight = false; + _backfillDone = true; + _logger.LogInformation( + "KPI rollup backfill skipped — rollups current through {Watermark:o}.", plan.Watermark); + return; + } + + EnqueueBackfillSlices(floor, toHourUtc); + _backfillInFlight = false; + + if (_backfillSlices.Count == 0) + { + // Degenerate window (no whole hours to fold) — nothing to backfill. + _backfillDone = true; + return; + } + + _logger.LogInformation( + "KPI rollup backfill starting — folding [{FromHour:o}, {ToHour:o}) across {SliceCount} " + + "slice(s) of up to {SliceHours} h (watermark {Watermark:o}).", + floor, toHourUtc, _backfillSlices.Count, BackfillSliceWidth.TotalHours, plan.Watermark); + + // Start draining via the timer (uniform path with the between-slice re-arm). + Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero); + } + + /// + /// Resolves the newest folded rollup hour (the backfill watermark) off the actor thread in a + /// fresh DI scope. Never faults — on cancellation or error it returns null, which the + /// plan treats as "no rollups", keeping the full retention window (best-effort, mirrors the + /// other passes). + /// + private async Task RunBackfillPlanPass(CancellationToken cancellationToken) + { + try + { + await using var scope = _serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.GetLatestRollupHourAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutdown interrupted the lookup; a fresh active node re-plans. Not a failure. + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "KPI rollup backfill watermark lookup failed; planning the full retention window."); + return null; + } + } + + /// + /// Runs one backfill fold slice: opens a DI scope, resolves the repository, and folds the /// complete hours in [, ) into - /// the hourly rollup table via the same idempotent upsert the periodic fold uses (Task 3). The - /// whole body is wrapped so the returned task never faults — best-effort observability must - /// never disrupt startup. The fold method returns no count, so completion is logged with the - /// window and elapsed wall time only. + /// the hourly rollup table via the same idempotent upsert the periodic fold uses. The whole + /// body is wrapped so the returned task never faults — best-effort observability must never + /// disrupt startup. The fold method returns no count, so each slice is logged at Debug with its + /// window and elapsed wall time (the queue-drained completion is logged at Information). /// private async Task RunBackfillPass( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken) @@ -543,8 +696,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers var repository = scope.ServiceProvider.GetRequiredService(); await repository.FoldHourlyRollupsAsync(fromHourUtc, toHourUtc, cancellationToken); - _logger.LogInformation( - "KPI rollup backfill completed — folded [{FromHour:o}, {ToHour:o}) in {ElapsedMs:F0} ms.", + _logger.LogDebug( + "KPI rollup backfill slice folded [{FromHour:o}, {ToHour:o}) in {ElapsedMs:F0} ms.", fromHourUtc, toHourUtc, (DateTime.UtcNow - startedAt).TotalMilliseconds); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -614,9 +767,11 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers } /// - /// Self-tick triggering the one-shot backfill fold over the full raw-retention window. Armed - /// once via a single timer in (and re-armed only to defer past an - /// in-flight periodic fold); runs at most once per actor lifetime. + /// Self-tick triggering the one-shot backfill: on the first tick it enumerates the full + /// raw-retention window into bounded slices, and each subsequent tick folds one slice. Armed + /// once via a single timer in (and re-armed to drain the next slice or + /// to defer past an in-flight periodic fold); the whole pass runs at most once per actor + /// lifetime. /// internal sealed class BackfillTick { @@ -625,13 +780,21 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers } /// - /// Piped-back completion of the one-shot backfill fold; lets the fold run off the actor thread - /// and, on the actor thread, lowers the _backfillInFlight guard and latches + /// Piped-back result of the backfill plan pass: the newest folded rollup hour (the watermark), + /// or null when no rollups exist. Consumed on the actor thread to skip or shrink the + /// backfill to the un-rolled tail (arch-review 04 round 2, R1). + /// + internal sealed record BackfillPlan(DateTime? Watermark); + + /// + /// Piped-back completion of ONE backfill fold slice; lets the slice run off the actor thread + /// and, on the actor thread, lowers the _backfillInFlight guard BETWEEN slices — then + /// either drains the next slice (self-armed timer) or, when the queue is empty, latches /// _backfillDone (fires on success and fault). /// - internal sealed class BackfillComplete + internal sealed class BackfillSliceComplete { - public static readonly BackfillComplete Instance = new(); - private BackfillComplete() { } + public static readonly BackfillSliceComplete Instance = new(); + private BackfillSliceComplete() { } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs index bfab3778..f7a2d994 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs @@ -42,7 +42,7 @@ public sealed class SiteCallAuditKpiSampleSource : IKpiSampleSource private const string MetricBuffered = KpiMetrics.SiteCallAudit.Buffered; private const string MetricParked = KpiMetrics.SiteCallAudit.Parked; private const string MetricFailedLastInterval = KpiMetrics.SiteCallAudit.FailedLastInterval; - private const string MetricDeliveredLastInterval = "deliveredLastInterval"; + private const string MetricDeliveredLastInterval = KpiMetrics.SiteCallAudit.DeliveredLastInterval; private const string MetricStuck = "stuck"; private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds"; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs index 77652d78..cdd4a7df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs @@ -63,6 +63,29 @@ public class SiteAuditRetentionServiceTests Assert.True(queue.PurgeCalls.Count >= 2); } + [Fact] + public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation() + { + // PurgeExpiredAsync blocks until cancelled, so shutdown lands mid-purge: SafePurgeAsync + // rethrows the OCE by design (abort the purge promptly), but the loop task must still + // complete CLEANLY — StopAsync hands _loop straight to the host, and a canceled task + // there is shutdown-log noise (arch-review 04 round 2, R7). + var queue = new RecordingSiteAuditQueue { BlockUntilCancelled = true }; + var options = Options.Create(new SiteAuditRetentionOptions + { + RetentionDays = 7, + PurgeInterval = TimeSpan.FromHours(24), + InitialDelay = TimeSpan.Zero, + }); + using var svc = new SiteAuditRetentionService(queue, options, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => queue.PurgeCalls.Count >= 1, TimeSpan.FromSeconds(5)); + + // Must NOT throw TaskCanceledException back into the host's shutdown path. + await svc.StopAsync(CancellationToken.None); + } + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var sw = Stopwatch.StartNew(); @@ -86,7 +109,11 @@ public class SiteAuditRetentionServiceTests public IReadOnlyCollection PurgeCalls => _purgeCalls; public bool ThrowOnFirstCall { get; init; } - public Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) + /// When set, PurgeExpiredAsync records the call then blocks until the token + /// cancels — simulating a shutdown that lands while a purge is in flight. + public bool BlockUntilCancelled { get; init; } + + public async Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) { var n = Interlocked.Increment(ref _calls); _purgeCalls.Enqueue(olderThanUtc); @@ -95,7 +122,12 @@ public class SiteAuditRetentionServiceTests throw new InvalidOperationException("Simulated transient purge failure."); } - return Task.FromResult(0); + if (BlockUntilCancelled) + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + + return 0; } public Task> ReadPendingAsync(int limit, CancellationToken ct = default) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs index 0dbc6b2c..4a45e5bd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs @@ -330,4 +330,95 @@ public class KpiHistoryQueryServiceTests Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + + // ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) ----------- + + [Fact] + public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath() + { + // (SiteCallAudit, FailedLastInterval) is a catalog Rate pair. 400 hourly points of + // value 1 over a 400 h (>168 h) window, maxPoints 200 → 2 points per 2 h bucket → + // every output value is 2.0 (the summed pair), not 1.0 (last value). + var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var to = from.AddHours(400); + var rollup = Enumerable.Range(0, 400) + .Select(i => new KpiSeriesPoint(from.AddHours(i), 1d)) + .ToList(); + + var repo = Substitute.For(); + repo.GetHourlySeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(rollup)); + + var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); + + var result = await sut.GetSeriesAsync( + KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, + "global", null, from, to); + + Assert.Equal(200, result.Count); + Assert.All(result, p => Assert.Equal(2.0, p.Value)); + } + + [Fact] + public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary() + { + // Same total event count (50) served as per-minute deltas on the raw path + // (window == threshold) and as hourly sums on the rollup path (window just over + // threshold): the summed chart total must be equal on both routes — the boundary + // is visually seamless for a Rate metric. + var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + + var rawDeltas = Enumerable.Range(0, 10) + .Select(i => new KpiSeriesPoint(from.AddMinutes(i * 10), 5d)).ToList(); // total 50 + var hourlySums = Enumerable.Range(0, 5) + .Select(i => new KpiSeriesPoint(from.AddHours(i), 10d)).ToList(); // total 50 + + var repo = Substitute.For(); + repo.GetRawSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(rawDeltas)); + repo.GetHourlySeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(hourlySums)); + + var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168)); + + var rawResult = await sut.GetSeriesAsync( + KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, + "global", null, from, from.AddHours(168)); // raw path + var rollupResult = await sut.GetSeriesAsync( + KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, + "global", null, from, from.AddHours(169)); // rollup path + + Assert.Equal(50d, rawResult.Sum(p => p.Value)); + Assert.Equal(50d, rollupResult.Sum(p => p.Value)); + Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value)); + } + + [Fact] + public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket() + { + // queueDepth (uncatalogued for this source pairing → Gauge) keeps the existing + // last-value-per-bucket output unchanged. 4 points, maxPoints 2, raw path. + var repo = Substitute.For(); + var raw = Series((0, 1d), (15, 2d), (25, 99d), (35, 5d)); + repo.GetRawSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(raw)); + + var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2)); + + var result = await sut.GetSeriesAsync( + KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.QueueDepth, + "global", null, From, To); + + Assert.Equal(2, result.Count); + Assert.Equal(99d, result[0].Value); // last value in bucket 0, not a sum + Assert.Equal(5d, result[1].Value); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs index 7bbae2bf..a3cb9b08 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs @@ -148,4 +148,22 @@ public class KpiMetricAggregationTests KpiRollupAggregation.Rate, KpiMetricAggregationCatalog.Resolve(KpiSources.SiteCallAudit, "deliveredLastInterval")); } + + // ---- Task 8: catalog metric literals promoted to shared KpiMetrics constants (R4) ---- + + [Fact] + public void PromotedMetricConstants_LockHistoricalPersistedValues() + { + // Persisted data contract — these are symbol promotions, NOT renames. + Assert.Equal("deliveredLastInterval", KpiMetrics.SiteCallAudit.DeliveredLastInterval); + Assert.Equal("alarmEvalErrors", KpiMetrics.SiteHealth.AlarmEvalErrors); + Assert.Equal("eventLogWriteFailures", KpiMetrics.SiteHealth.EventLogWriteFailures); + } + + [Theory] + [InlineData(KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.DeliveredLastInterval)] + [InlineData(KpiSources.SiteHealth, KpiMetrics.SiteHealth.AlarmEvalErrors)] + [InlineData(KpiSources.SiteHealth, KpiMetrics.SiteHealth.EventLogWriteFailures)] + public void Resolve_PromotedRatePairs_StillClassifyAsRate(string source, string metric) + => Assert.Equal(KpiRollupAggregation.Rate, KpiMetricAggregationCatalog.Resolve(source, metric)); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs index 9e40da55..4b08b22b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs @@ -369,4 +369,97 @@ public class KpiSeriesBucketerTests Assert.Equal(4.0, result[0].Value); Assert.Equal(9.0, result[1].Value); } + + // ----------------------------------------------------------------------- + // Per-metric reduction: Rate series sum per bucket (arch-review 04 round 2, R3) + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_RateSeries_SumsPerBucket_InsteadOfLastValue() + { + // 6 points of value 10 across a 60-min / 2-bucket window → each bucket = 30 (sum), + // not 10 (last value). Three deltas per bucket; last-value would keep 1 of 3. + var raw = new[] + { + new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 30) + new KpiSeriesPoint(T(15), 10.0), // bucket 0 + new KpiSeriesPoint(T(25), 10.0), // bucket 0 + new KpiSeriesPoint(T(35), 10.0), // bucket 1: [30, 60] + new KpiSeriesPoint(T(45), 10.0), // bucket 1 + new KpiSeriesPoint(T(55), 10.0), // bucket 1 + }; + + var result = KpiSeriesBucketer.Bucket( + raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Rate); + + Assert.Equal(2, result.Count); + Assert.Equal(30.0, result[0].Value); + Assert.Equal(30.0, result[1].Value); + } + + [Fact] + public void Bucket_RateSeries_ShortSeries_StillSumsClusteredPoints() + { + // 3 points (< maxPoints 5) with two sharing a bucket → the shared bucket is their SUM. + // Rate series skip the raw.Count <= maxPoints early return so totals stay truthful. + // 60-min / 5-bucket window → 12 min each. T(2),T(5) → bucket 0 [0,12); T(30) → bucket 2 [24,36). + var raw = new[] + { + new KpiSeriesPoint(T(2), 10.0), + new KpiSeriesPoint(T(5), 10.0), + new KpiSeriesPoint(T(30), 10.0), + }; + + var result = KpiSeriesBucketer.Bucket( + raw, T(0), T(60), maxPoints: 5, aggregation: KpiRollupAggregation.Rate); + + Assert.Equal(2, result.Count); + Assert.Equal(20.0, result[0].Value); // T(2)+T(5) summed + Assert.Equal(10.0, result[1].Value); // T(30) alone + } + + [Fact] + public void Bucket_RateSeries_PreservesSeriesTotal() + { + // Strong invariant: sum(output values) == sum(in-window input values) for Rate. + var raw = Enumerable + .Range(0, 20) + .Select(i => new KpiSeriesPoint(T(i * 3), (double)(i + 1))) + .ToArray(); + + var result = KpiSeriesBucketer.Bucket( + raw, T(0), T(60), maxPoints: 4, aggregation: KpiRollupAggregation.Rate); + + var inWindowTotal = raw + .Where(p => p.BucketStartUtc >= T(0) && p.BucketStartUtc <= T(60)) + .Sum(p => p.Value); + Assert.Equal(inWindowTotal, result.Sum(p => p.Value)); + } + + [Fact] + public void Bucket_DefaultAggregation_IsGauge_AndBehaviorUnchanged() + { + // Calling the existing 4-arg shape produces identical output to the explicit Gauge call. + var raw = new[] + { + new KpiSeriesPoint(T(5), 1.0), + new KpiSeriesPoint(T(15), 2.0), + new KpiSeriesPoint(T(25), 99.0), + new KpiSeriesPoint(T(35), 5.0), + }; + + var fourArg = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2); + var explicitGauge = KpiSeriesBucketer.Bucket( + raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Gauge); + + Assert.Equal(fourArg.Count, explicitGauge.Count); + for (int i = 0; i < fourArg.Count; i++) + { + Assert.Equal(fourArg[i].BucketStartUtc, explicitGauge[i].BucketStartUtc); + Assert.Equal(fourArg[i].Value, explicitGauge[i].Value); + } + // And the last-value semantics are unchanged. + Assert.Equal(99.0, fourArg[0].Value); + Assert.Equal(5.0, fourArg[1].Value); + } } 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 68822e4e..195b36b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs @@ -386,10 +386,153 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } + [Fact] + public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + await repo.RecordSamplesAsync(new[] + { + Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), + Sample("NotificationOutbox", "queueDepth", "Global", null, 7, Base.AddMinutes(50)), + }); + ctx.ChangeTracker.Clear(); // isolate the fold's tracking behavior from the seed + + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); + + // The fold READS samples and WRITES rollups; the read must not register + // thousands of read-only KpiSample entries for DetectChanges to re-scan + // (arch-review 04 round 2, R2). + Assert.Empty(ctx.ChangeTracker.Entries()); + } + + [Fact] + public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + Assert.Null(await repo.GetLatestRollupHourAsync()); + } + + [Fact] + public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + await repo.RecordSamplesAsync(new[] + { + Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), + Sample("SiteCallAudit", "buffered", "Global", null, 2, Base.AddHours(3).AddMinutes(10)), + }); + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(4)); + + Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync()); + } + + // ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ---- + + [Fact] + public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals() + { + // A single shared in-memory SQLite connection so a second context (the interceptor's + // side-write, and the "next tick" fold) sees the same database. + await using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using (var schema = ContextOn(connection)) + { + await schema.Database.EnsureCreatedAsync(); + } + + // The old singleton incarnation's in-flight fold wins the (series, hour) upsert race: + // the interceptor inserts the conflicting rollup row on the same connection just before + // THIS fold's SaveChanges, so the unique IX_KpiRollupHourly_Series faults the loser's save. + // A non-null ScopeKey (site-scoped series) is used deliberately: SQLite treats NULLs as + // distinct in a UNIQUE index, so only a non-null key deterministically forces the conflict. + var interceptor = new InsertConflictingRollupOnFirstSave(connection, () => new KpiRollupHourly + { + Source = "NotificationOutbox", Metric = "queueDepth", Scope = "Site", ScopeKey = "plant-a", + HourStartUtc = Base, Value = -1, MinValue = -1, MaxValue = -1, SampleCount = 1, + }); + + await using var ctx = ContextOn(connection, interceptor); + var repo = new KpiHistoryRepository(ctx); + await repo.RecordSamplesAsync(new[] + { + Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", 5, Base.AddMinutes(10)), + }); + + // Losing the race must NOT propagate DbUpdateException — the failure grain is the pass. + var ex = await Record.ExceptionAsync(() => repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1))); + Assert.Null(ex); + + // Next tick's fold (a fresh scope/context over the same DB) self-heals: it finds the + // conflicting row and idempotently upserts it in place to the correct folded value. + await using var ctx2 = ContextOn(connection); + var repo2 = new KpiHistoryRepository(ctx2); + await repo2.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); + + var series = await repo2.GetHourlySeriesAsync( + "NotificationOutbox", "queueDepth", "Site", "plant-a", Base, Base.AddHours(1)); + Assert.Single(series); + Assert.Equal(5, series[0].Value); // the fold's own value overwrote the side-inserted -1 + } + // Local mirror of the repo's private hour-truncation, for cutoff assertions. private static DateTime TruncateHour(DateTime utc) => new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); + /// Builds a SQLite test context bound to an externally-owned open connection + /// (so multiple contexts share one in-memory database), with optional interceptors. + private static ScadaBridgeDbContext ContextOn(DbConnection connection, params IInterceptor[] interceptors) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .AddInterceptors(interceptors) + .Options; + return new SqliteTestDbContext(options); + } + + /// + /// SaveChanges interceptor that, exactly once and only when a fold is about to insert a + /// , inserts a conflicting (series, hour) rollup row through a + /// second context on the same connection — deterministically reproducing the failover-overlap + /// upsert race the losing fold must survive (arch-review 04 round 2, R5). + /// + private sealed class InsertConflictingRollupOnFirstSave : Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor + { + private readonly DbConnection _connection; + private readonly Func _rowFactory; + private bool _fired; + + public InsertConflictingRollupOnFirstSave(DbConnection connection, Func rowFactory) + { + _connection = connection; + _rowFactory = rowFactory; + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (!_fired && eventData.Context is not null && + eventData.Context.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added)) + { + _fired = true; + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + await using var side = new SqliteTestDbContext(options); + side.KpiRollupHourly.Add(_rowFactory()); + await side.SaveChangesAsync(cancellationToken); + } + + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + } + /// /// EF command interceptor that counts the DELETE statements actually issued /// to the store, so a test can prove the purge is sliced into multiple 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 47aa2799..79569052 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -1,4 +1,6 @@ +using System.Data.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; @@ -474,6 +476,69 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.NotNull(await repo.GetAsync(recentTerminalId)); } + [SkippableFact] + public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var site = NewSiteId(); + var now = DateTime.UtcNow; + var cutoff = now.AddDays(-1); + + // Attach a DELETE-counting interceptor so we can prove the purge issues MORE THAN ONE + // DELETE for a multi-day backlog (arch-review 04 round 2, R6 — time-sliced batching). + var deleteCounter = new DeleteCountingCommandInterceptor(); + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .AddInterceptors(deleteCounter) + .Options; + await using var context = new ScadaBridgeDbContext(options); + var repo = new SiteCallAuditRepository(context); + + // Three terminal rows on three DISTINCT days beyond the cutoff — a multi-day backlog. + var oldIds = new List(); + foreach (var days in new[] { 3, 4, 5 }) + { + var id = TrackedOperationId.New(); + oldIds.Add(id); + var terminalAt = now.AddDays(-days); + await repo.UpsertAsync(NewRow( + id, sourceSite: site, status: "Delivered", retryCount: 0, + createdAtUtc: terminalAt.AddMinutes(-1), updatedAtUtc: terminalAt, + terminal: true, terminalAtUtc: terminalAt)); + } + + // A fresh terminal row inside the keep window — must survive. + var recentTerminalId = TrackedOperationId.New(); + await repo.UpsertAsync(NewRow( + recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0, + createdAtUtc: now.AddHours(-2), updatedAtUtc: now.AddHours(-1), + terminal: true, terminalAtUtc: now.AddHours(-1))); + + // An OLD non-terminal row — non-terminal rows are NEVER purged on age. + var oldActiveId = TrackedOperationId.New(); + await repo.UpsertAsync(NewRow( + oldActiveId, sourceSite: site, status: "Attempted", retryCount: 1, + createdAtUtc: now.AddDays(-10), updatedAtUtc: now.AddDays(-10), + terminal: false)); + + deleteCounter.Reset(); + var purged = await repo.PurgeTerminalAsync(cutoff); + + // The three seeded backlog rows are gone; the fresh terminal + old non-terminal survive. + Assert.True(purged >= 3, $"Expected at least the three backlog rows purged; got {purged}."); + foreach (var id in oldIds) + { + Assert.Null(await repo.GetAsync(id)); + } + Assert.NotNull(await repo.GetAsync(recentTerminalId)); + Assert.NotNull(await repo.GetAsync(oldActiveId)); + + // Slicing: a multi-day backlog is broken into MORE THAN ONE DELETE (one per day slice). + Assert.True(deleteCounter.DeleteCount > 1, + $"Expected the multi-day purge to slice into >1 DELETE; issued {deleteCounter.DeleteCount}."); + } + // --- KPI snapshot tests ------------------------------------------------- [SkippableFact] @@ -824,4 +889,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.Equal("Attempted", loaded.Status); } } + + /// + /// EF command interceptor that counts the DELETE statements actually issued to the store, + /// so a test can prove the terminal purge slices a multi-day backlog into several windowed + /// DELETEs rather than one year-scale statement. ExecuteSqlInterpolatedAsync routes + /// through the async non-query path. + /// + private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor + { + public int DeleteCount { get; private set; } + + public void Reset() => DeleteCount = 0; + + private void CountIfDelete(DbCommand command) + { + if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase)) + { + DeleteCount++; + } + } + + public override InterceptionResult NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + CountIfDelete(command); + return base.NonQueryExecuting(command, eventData, result); + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + CountIfDelete(command); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs index e668b9b0..a7ea337c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs @@ -94,6 +94,22 @@ public class SchemaConfigurationTests : IDisposable new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status) }, index.GetIncludeProperties()); } + + // arch-review 04 round 2 (R6): the daily terminal retention purge filters on + // TerminalAtUtc IS NOT NULL AND TerminalAtUtc < cutoff. A filtered index over the + // terminal population lets the purge (and Task 11's MIN() slice anchor) seek the + // expired tail instead of full-scanning the 365-day table. Locked shape: + // key = TerminalAtUtc, filter = [TerminalAtUtc] IS NOT NULL. + [Fact] + public void SiteCalls_Has_Filtered_Terminal_Index() + { + var model = _context.GetService().Model; + var entity = model.FindEntityType(typeof(SiteCall))!; + var index = entity.GetIndexes().Single(i => i.GetDatabaseName() == "IX_SiteCalls_Terminal"); + + Assert.Equal(new[] { nameof(SiteCall.TerminalAtUtc) }, index.Properties.Select(p => p.Name).ToArray()); + Assert.Equal("[TerminalAtUtc] IS NOT NULL", index.GetFilter()); + } } public class SplitQueryBehaviourTests : IDisposable diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs index 7a98c068..a9f31382 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs @@ -111,6 +111,9 @@ public class KpiHistoryRecorderActorTests : TestKit private DateTime? _foldFromHourUtc; private DateTime? _foldToHourUtc; private int _foldCount; + private readonly List<(DateTime FromHourUtc, DateTime ToHourUtc)> _foldWindows = new(); + private DateTime? _latestRollupHour; + private int _latestRollupHourQueryCount; public IReadOnlyList Recorded { @@ -148,6 +151,12 @@ public class KpiHistoryRecorderActorTests : TestKit get { lock (_gate) { return _foldCount; } } } + /// Every fold window handed to , in call order. + public IReadOnlyList<(DateTime FromHourUtc, DateTime ToHourUtc)> FoldWindows + { + get { lock (_gate) { return _foldWindows.ToArray(); } } + } + public Task RecordSamplesAsync( IReadOnlyCollection samples, CancellationToken cancellationToken = default) { @@ -176,6 +185,7 @@ public class KpiHistoryRecorderActorTests : TestKit { _foldFromHourUtc = fromHourUtc; _foldToHourUtc = toHourUtc; + _foldWindows.Add((fromHourUtc, toHourUtc)); _foldCount++; } return Task.CompletedTask; @@ -191,6 +201,28 @@ public class KpiHistoryRecorderActorTests : TestKit lock (_gate) { _rollupPurgeCutoff = before; } return Task.CompletedTask; } + + /// Watermark the fake returns from (settable). + public DateTime? LatestRollupHour + { + get { lock (_gate) { return _latestRollupHour; } } + set { lock (_gate) { _latestRollupHour = value; } } + } + + /// Number of times the backfill plan pass queried the rollup watermark. + public int LatestRollupHourQueryCount + { + get { lock (_gate) { return _latestRollupHourQueryCount; } } + } + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) + { + lock (_gate) + { + _latestRollupHourQueryCount++; + return Task.FromResult(_latestRollupHour); + } + } } /// @@ -237,6 +269,9 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } /// @@ -283,6 +318,9 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } /// @@ -340,6 +378,92 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); + } + + /// + /// Repository fake for the backfill-slice interleaving test. Classifies each fold as a backfill + /// slice (24 h window) or a periodic fold (the distinct RollupLookbackHours-wide window) + /// by window width. Backfill slices complete immediately (counted); the FIRST periodic fold + /// blocks in flight until released, so the test can observe that a periodic fold claimed the + /// fold path between backfill slices while backfill slices remained. + /// + private sealed class InterleaveFoldRepository : IKpiHistoryRepository + { + private readonly TimeSpan _periodicWindowWidth; + private readonly object _gate = new(); + private readonly SemaphoreSlim _firstPeriodicRelease = new(initialCount: 0); + private int _backfillFoldCount; + private int _periodicFoldCount; + private int _backfillCountAtFirstPeriodic = -1; + + public InterleaveFoldRepository(TimeSpan periodicWindowWidth) => + _periodicWindowWidth = periodicWindowWidth; + + /// Number of backfill-slice folds (24 h windows) entered so far. + public int BackfillFoldCount { get { lock (_gate) { return _backfillFoldCount; } } } + + /// Number of periodic folds (the lookback-width window) entered so far. + public int PeriodicFoldCount { get { lock (_gate) { return _periodicFoldCount; } } } + + /// Backfill-slice count observed at the instant the first periodic fold entered. + public int BackfillCountAtFirstPeriodic { get { lock (_gate) { return _backfillCountAtFirstPeriodic; } } } + + /// Releases the first (held-in-flight) periodic fold so the deferred backfill resumes. + public void ReleaseFirstPeriodic() => _firstPeriodicRelease.Release(); + + public Task RecordSamplesAsync( + IReadOnlyCollection samples, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task> GetRawSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => + Task.FromResult(0); + + public Task FoldHourlyRollupsAsync( + DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) + { + var isPeriodic = (toHourUtc - fromHourUtc) == _periodicWindowWidth; + if (isPeriodic) + { + bool blockThis; + lock (_gate) + { + _periodicFoldCount++; + blockThis = _periodicFoldCount == 1; + if (blockThis) + { + _backfillCountAtFirstPeriodic = _backfillFoldCount; + } + } + + // Hold the first periodic fold in flight (on a threadpool thread — PipeTo runs the + // pass off the actor thread) so the between-slice interleave state is observable. + return blockThis + ? Task.Run(() => _firstPeriodicRelease.Wait(cancellationToken), cancellationToken) + : Task.CompletedTask; + } + + lock (_gate) { _backfillFoldCount++; } + return Task.CompletedTask; + } + + public Task> GetHourlySeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } private IServiceProvider BuildServiceProvider( @@ -671,13 +795,15 @@ public class KpiHistoryRecorderActorTests : TestKit public void BackfillTick_FoldsFullRetentionWindow_Once() { // The one-shot backfill must fold the WHOLE raw-retention window once, so long-range - // (30 d/90 d) charts aren't blank until enough wall-clock passes after deploy: the fold's - // upper bound is the current hour start (in-progress hour excluded) and its lower bound is - // RetentionDays earlier. A second BackfillTick to the same actor must be a no-op (the - // _backfillDone latch makes it run at most once per actor lifetime). + // (30 d/90 d) charts aren't blank until enough wall-clock passes after deploy. It now + // does so in bounded <=24 h slices (R1) rather than a single unbounded fold: the union + // of the slice windows must cover exactly [truncate(now) − RetentionDays, truncate(now)), + // each slice must be <=24 h, and the slices must be contiguous and oldest-first. A second + // BackfillTick to the same actor must be a no-op (the _backfillDone latch makes the whole + // pass run at most once per actor lifetime). var repository = new RecordingRepository(); var sp = BuildServiceProvider(repository, new HealthySource()); - const int retentionDays = 90; + const int retentionDays = 5; var actor = CreateActor(sp, new KpiHistoryOptions { SampleInterval = TimeSpan.FromHours(1), @@ -691,52 +817,168 @@ public class KpiHistoryRecorderActorTests : TestKit AwaitAssert( () => { - Assert.Equal(1, repository.FoldCount); - Assert.NotNull(repository.FoldToHourUtc); - Assert.NotNull(repository.FoldFromHourUtc); - - var toHour = repository.FoldToHourUtc!.Value; - var fromHour = repository.FoldFromHourUtc!.Value; - - // toHour is the truncated current hour (in-progress hour excluded). - Assert.Equal(DateTimeKind.Utc, toHour.Kind); - Assert.Equal(0, toHour.Minute); - Assert.Equal(0, toHour.Second); - Assert.Equal(0, toHour.Millisecond); + var windows = repository.FoldWindows; + // RetentionDays = 5 → exactly 5 contiguous 24 h slices. + Assert.Equal(retentionDays, windows.Count); var expectedToHour = new DateTime( DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc); - // Within one hour of "now truncated" (guards a rare hour-boundary crossing mid-test). - Assert.True( - Math.Abs((toHour - expectedToHour).TotalHours) <= 1.0, - $"backfill toHour {toHour:o} should be the current hour start {expectedToHour:o}"); + var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays); - // fromHour is exactly RetentionDays (the full raw-retention window) before toHour. - Assert.Equal(toHour - TimeSpan.FromDays(retentionDays), fromHour); + // Oldest-first, contiguous, each <=24 h, union == full retention window. + Assert.Equal(expectedFromHour, windows[0].FromHourUtc); + Assert.Equal(expectedToHour, windows[^1].ToHourUtc); + for (var i = 0; i < windows.Count; i++) + { + Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24), + $"slice {i} width {windows[i].ToHourUtc - windows[i].FromHourUtc} exceeds 24 h"); + if (i > 0) + { + Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous + } + } }, - duration: TimeSpan.FromSeconds(3), + duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50)); - // Second BackfillTick to the SAME actor: the once-only latch must suppress it, so the - // fold count stays at 1. (No periodic RollupTick is sent, and the periodic/backfill - // auto-timers don't fire within this sub-10 s window, so FoldCount is driven solely by - // the two backfill ticks under test.) + // Second BackfillTick to the SAME actor: the once-only latch must suppress the whole pass, + // so no further slices are folded. (No periodic RollupTick is sent, and the periodic/backfill + // auto-timers don't fire within this window, so the fold windows are driven solely by the + // backfill under test.) + var foldedCount = repository.FoldWindows.Count; actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); Thread.Sleep(300); - Assert.Equal(1, repository.FoldCount); + Assert.Equal(foldedCount, repository.FoldWindows.Count); + } + + [Fact] + public void Backfill_SlicesRetentionWindow_IntoBoundedDayFolds_OldestFirst() + { + // RetentionDays = 3 → 3 fold calls, each window <=24 h, contiguous, oldest-first, whose + // union is exactly [TruncateToHour(now) − 3 d, TruncateToHour(now)). Pre-R1 a single fold + // call spanned the whole 3-day window (arch-review 04 round 2, R1). + var repository = new RecordingRepository(); + var sp = BuildServiceProvider(repository, new HealthySource()); + const int retentionDays = 3; + var actor = CreateActor(sp, new KpiHistoryOptions + { + SampleInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromHours(1), + RollupInterval = TimeSpan.FromHours(1), + RetentionDays = retentionDays, + }); + + actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); + + AwaitAssert( + () => + { + var windows = repository.FoldWindows; + Assert.Equal(retentionDays, windows.Count); // 3 × 24 h slices + + var expectedToHour = new DateTime( + DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, + DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc); + var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays); + + Assert.Equal(expectedFromHour, windows[0].FromHourUtc); // oldest first + Assert.Equal(expectedToHour, windows[^1].ToHourUtc); // newest last + for (var i = 0; i < windows.Count; i++) + { + Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24), + $"slice {i} exceeds 24 h"); + if (i > 0) + { + Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous + } + } + }, + duration: TimeSpan.FromSeconds(5), + interval: TimeSpan.FromMilliseconds(50)); + } + + [Fact] + public async Task PeriodicRollupTick_Interleaves_BetweenBackfillSlices() + { + // A periodic fold must be able to claim the fold path BETWEEN backfill slices: while the + // historical backfill runs slice-by-slice, a RollupTick that lands in a between-slice gap + // runs its (recent-window) fold and the remaining backfill slices defer behind it, then + // ALL backfill slices still complete afterwards. Pre-R1 _backfillInFlight blocked every + // periodic fold for the whole (single-fold) pass, so no interleaving was possible + // (arch-review 04 round 2, R1). + const int retentionDays = 8; // 8 backfill slices of 24 h + const int lookbackHours = 2; // periodic fold window width — distinct from the 24 h slices + var repository = new InterleaveFoldRepository(TimeSpan.FromHours(lookbackHours)); + var sp = BuildServiceProvider(repository, new HealthySource()); + var actor = CreateActor(sp, new KpiHistoryOptions + { + SampleInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromHours(1), + RollupInterval = TimeSpan.FromHours(24), // periodic auto-timer won't fire during the test + RetentionDays = retentionDays, + RollupLookbackHours = lookbackHours, + }); + + actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); + + // Continuously offer periodic ticks; one will win a between-slice gap and interleave. + using var stopFlood = new CancellationTokenSource(); + var flood = Task.Run(() => + { + while (!stopFlood.IsCancellationRequested) + { + actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance); + Thread.Sleep(1); + } + }); + + try + { + // A periodic fold interleaved BEFORE the backfill finished all its slices. + AwaitAssert( + () => + { + Assert.True(repository.PeriodicFoldCount >= 1, "no periodic fold interleaved"); + Assert.True( + repository.BackfillCountAtFirstPeriodic < retentionDays, + "the periodic fold did not interleave before the backfill completed"); + }, + duration: TimeSpan.FromSeconds(10), + interval: TimeSpan.FromMilliseconds(20)); + } + finally + { + stopFlood.Cancel(); + await flood; + } + + // The first periodic fold is held in flight, deferring the remaining backfill slices; + // release it so the deferred backfill resumes and ALL slices still complete. + repository.ReleaseFirstPeriodic(); + AwaitAssert( + () => Assert.Equal(retentionDays, repository.BackfillFoldCount), + duration: TimeSpan.FromSeconds(10), + interval: TimeSpan.FromMilliseconds(50)); } [Fact] public void BackfillInFlight_SkipsPeriodicRollupTick() { - // While the (possibly long) full-window backfill fold is in flight, a periodic RollupTick - // must be skipped so the two idempotent upserts never race on overlapping rows. With a - // gated fold holding the backfill open, a RollupTick must NOT enter a second fold — the - // fold count stays at 1 until the gate is released. + // While a backfill slice fold is in flight, a periodic RollupTick must be skipped so the + // two idempotent upserts never race on overlapping rows (unchanged semantics — a RollupTick + // arriving while a *slice* is in flight is still skipped). With a gated fold holding the + // single backfill slice open, a RollupTick must NOT enter a second fold — the fold count + // stays at 1 until the gate is released. RetentionDays = 1 → exactly one 24 h backfill slice. var repository = new GatedFoldRepository(); var sp = BuildServiceProvider(repository, new HealthySource()); - var actor = CreateActor(sp); + var actor = CreateActor(sp, new KpiHistoryOptions + { + SampleInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromHours(1), + RollupInterval = TimeSpan.FromHours(1), + RetentionDays = 1, + }); // Backfill tick: raises the backfill guard, enters the (gated, blocking) fold — held in flight. actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); @@ -753,7 +995,7 @@ public class KpiHistoryRecorderActorTests : TestKit // Release the gate so the backfill completes and its guard is lowered; a fresh periodic // tick must now run a new fold (guards correctly reset, not stuck). Re-send on each poll so - // we don't race the asynchronous BackfillComplete that lowers the guard. + // we don't race the asynchronous BackfillSliceComplete that lowers the guard. repository.Release(); AwaitAssert( () => @@ -764,4 +1006,85 @@ public class KpiHistoryRecorderActorTests : TestKit duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); } + + [Fact] + public void Backfill_Skips_WhenRollupsAlreadyCurrent() + { + // Watermark within RollupLookbackHours of now (the common failover case): the backfill + // must be skipped entirely — ZERO folds, the done-latch set — because the periodic fold's + // lookback window already covers everything from the watermark forward (arch-review 04 + // round 2, R1). + var repository = new RecordingRepository(); + var sp = BuildServiceProvider(repository, new HealthySource()); + var actor = CreateActor(sp, new KpiHistoryOptions + { + SampleInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromHours(1), + RollupInterval = TimeSpan.FromHours(1), + RetentionDays = 90, + RollupLookbackHours = 3, + }); + + // Rollups current through one hour ago — well inside the 3 h lookback tail. + var nowHour = new DateTime( + DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, + DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc); + repository.LatestRollupHour = nowHour - TimeSpan.FromHours(1); + + actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); + + // The plan pass must consult the watermark… + AwaitAssert( + () => Assert.True(repository.LatestRollupHourQueryCount >= 1), + duration: TimeSpan.FromSeconds(3), + interval: TimeSpan.FromMilliseconds(50)); + + // …and then skip: no slices are ever folded. + Thread.Sleep(300); + Assert.Equal(0, repository.FoldCount); + + // A second BackfillTick is a no-op (the done-latch is set). + actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); + Thread.Sleep(300); + Assert.Equal(0, repository.FoldCount); + } + + [Fact] + public void Backfill_ShrinksToWatermark_InsteadOfRetentionFloor() + { + // RetentionDays = 90 but the watermark is only 2 days old: the first slice must start at + // the WATERMARK hour (inclusive — re-folding the newest already-folded hour is a cheap + // idempotent safety margin), NOT 90 days ago, and there must be <= 3 slices total + // (arch-review 04 round 2, R1). + var repository = new RecordingRepository(); + var sp = BuildServiceProvider(repository, new HealthySource()); + var actor = CreateActor(sp, new KpiHistoryOptions + { + SampleInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromHours(1), + RollupInterval = TimeSpan.FromHours(1), + RetentionDays = 90, + RollupLookbackHours = 3, + }); + + var nowHour = new DateTime( + DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, + DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc); + var watermark = nowHour - TimeSpan.FromDays(2); + repository.LatestRollupHour = watermark; + + actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance); + + AwaitAssert( + () => + { + var windows = repository.FoldWindows; + Assert.NotEmpty(windows); + // Shrunk to the un-rolled tail: first slice starts AT the watermark, not 90 d ago. + Assert.Equal(watermark, windows[0].FromHourUtc); + Assert.True(windows.Count <= 3, $"expected <= 3 slices from the 2-day tail, got {windows.Count}"); + }, + duration: TimeSpan.FromSeconds(5), + interval: TimeSpan.FromMilliseconds(50)); + } }