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