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; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; /// /// EF Core implementation of over the central /// KpiSample table ("KPI History & Trends"). See the interface for the /// contract; this class adds notes on the data-access strategy per method. /// 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. /// /// 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; } /// public async Task RecordSamplesAsync( IReadOnlyCollection samples, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(samples); // Avoid a no-op SaveChanges round-trip on quiet sampling ticks. if (samples.Count == 0) { return; } // Bulk-insert one sampling pass. AddRange + a single SaveChanges keeps the // whole batch in one round-trip; the store assigns each row's identity. _context.KpiSamples.AddRange(samples); await _context.SaveChangesAsync(cancellationToken); } /// public async Task> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) { // The ScopeKey == scopeKey comparison is intentional: when scopeKey is null // EF translates it to "ScopeKey IS NULL", which matches the Global-scope rows // (null key) and excludes the site/node-scoped rows that carry a non-null key. return await _context.KpiSamples .Where(s => s.Source == source && s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey && s.CapturedAtUtc >= fromUtc && s.CapturedAtUtc <= toUtc) .OrderBy(s => s.CapturedAtUtc) .Select(s => new KpiSeriesPoint(s.CapturedAtUtc, s.Value)) .ToListAsync(cancellationToken); } /// public async Task PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) { // Time-sliced batches: each DELETE covers at most one hour of samples, capping the // lock/log footprint per statement (arch-review 04, P4 — steady state is ~1 day of // rows/day; after an outage the catch-up would otherwise be one giant transaction). var total = 0; var floor = await _context.KpiSamples.Where(s => s.CapturedAtUtc < before) .MinAsync(s => (DateTime?)s.CapturedAtUtc, cancellationToken); while (floor is not null && floor < before) { var ceiling = floor.Value.AddHours(1) < before ? floor.Value.AddHours(1) : before; total += await _context.KpiSamples .Where(s => s.CapturedAtUtc < ceiling) .ExecuteDeleteAsync(cancellationToken); floor = await _context.KpiSamples.Where(s => s.CapturedAtUtc < before) .MinAsync(s => (DateTime?)s.CapturedAtUtc, cancellationToken); } return total; } /// public async Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) { // Truncate the window to whole-hour boundaries. The fold covers the complete hours // in [truncate(from), truncate(to)) — the upper bound is EXCLUSIVE, so a caller that // passes the current hour's start as toHourUtc never folds the in-progress hour. var from = TruncateToHour(fromHourUtc); var to = TruncateToHour(toHourUtc); if (to <= from) { return; } // The caller passes a small trailing lookback (e.g. 3 h), so the window is bounded: // 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) { return; } var groups = samples.GroupBy(s => new SeriesHourKey( s.Source, s.Metric, s.Scope, s.ScopeKey, TruncateToHour(s.CapturedAtUtc))); // Preload every rollup row already covering this window in ONE query and // index it by series+hour (WP2.2). The predecessor issued a // FirstOrDefaultAsync existence probe PER (series, hour) group — an N+1 // that scaled with the metric catalogue times the lookback: a 3 h re-fold // over ~40 series cost ~120 sequential round trips before a single row was // written. The window is bounded by the caller's small trailing lookback // and the rollup table holds exactly one row per series-hour, so the // preload is a narrow range seek on IX_KpiRollupHourly_Series. // // Deliberately TRACKED (not a projection): the re-fold path mutates the // existing entity in place and relies on the change tracker to emit the // UPDATE. The dictionary's ScopeKey comparison is ordinal where the // previous SQL predicate used the database collation; both sides are // written from the same KpiSample.ScopeKey values, so they are // byte-identical in practice and a residual mismatch degrades to the // already-handled upsert-race path rather than a wrong aggregate. var existingRollups = await _context.KpiRollupHourly .Where(r => r.HourStartUtc >= from && r.HourStartUtc < to) .ToListAsync(cancellationToken); var existingByKey = new Dictionary(existingRollups.Count); foreach (var row in existingRollups) { existingByKey[new SeriesHourKey( row.Source, row.Metric, row.Scope, row.ScopeKey, DateTime.SpecifyKind(row.HourStartUtc, DateTimeKind.Utc))] = row; } foreach (var group in groups) { var key = group.Key; // Per-metric aggregation intent: Rate metrics sum the hour (last-value would // discard 59 of 60 intra-hour deltas); Gauge metrics keep the last (latest // timestamp) value in the hour. Min/Max/Count preserve the fold's fidelity. var aggregation = KpiMetricAggregationCatalog.Resolve(key.Source, key.Metric); var value = aggregation == KpiRollupAggregation.Rate ? group.Sum(s => s.Value) : group.OrderByDescending(s => s.CapturedAtUtc).First().Value; var minValue = group.Min(s => s.Value); var maxValue = group.Max(s => s.Value); var sampleCount = group.Count(); // Idempotent upsert on the unique series+hour key, resolved against the // preloaded dictionary. A null ScopeKey keys the Global-scope rows exactly // as the UNIQUE IX_KpiRollupHourly_Series index treats a null key as // participating. if (!existingByKey.TryGetValue(key, out var existing)) { _context.KpiRollupHourly.Add(new KpiRollupHourly { Source = key.Source, Metric = key.Metric, Scope = key.Scope, ScopeKey = key.ScopeKey, HourStartUtc = key.HourStartUtc, Value = value, MinValue = minValue, MaxValue = maxValue, SampleCount = sampleCount, }); } else { // Re-fold overwrites the aggregate in place — a re-run over the same window // produces identical values (no double-count), so a missed tick self-heals. existing.Value = value; existing.MinValue = minValue; existing.MaxValue = maxValue; existing.SampleCount = sampleCount; } } 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."); } } /// public async Task> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) { // Same null-ScopeKey semantics as GetRawSeriesAsync: scopeKey == null translates to // "ScopeKey IS NULL", matching Global-scope rollups and excluding site/node-keyed ones. return await _context.KpiRollupHourly .Where(r => r.Source == source && r.Metric == metric && r.Scope == scope && r.ScopeKey == scopeKey && r.HourStartUtc >= fromUtc && r.HourStartUtc <= toUtc) .OrderBy(r => r.HourStartUtc) .Select(r => new KpiSeriesPoint(r.HourStartUtc, r.Value)) .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) { // Mirror PurgeOlderThanAsync: one-hour-sliced batched DELETEs cap the lock/log // footprint per statement (arch-review 04, P4). Rollups are one-per-hour, so each // slice deletes a single hour bucket. var floor = await _context.KpiRollupHourly.Where(r => r.HourStartUtc < before) .MinAsync(r => (DateTime?)r.HourStartUtc, cancellationToken); while (floor is not null && floor < before) { var ceiling = floor.Value.AddHours(1) < before ? floor.Value.AddHours(1) : before; await _context.KpiRollupHourly .Where(r => r.HourStartUtc < ceiling) .ExecuteDeleteAsync(cancellationToken); floor = await _context.KpiRollupHourly.Where(r => r.HourStartUtc < before) .MinAsync(r => (DateTime?)r.HourStartUtc, cancellationToken); } } /// Truncates a UTC timestamp to the start of its hour (minutes/seconds/ticks zeroed). private static DateTime TruncateToHour(DateTime utc) => new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); /// 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); }