284 lines
14 KiB
C#
284 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// EF Core implementation of <see cref="IKpiHistoryRepository"/> over the central
|
|
/// <c>KpiSample</c> table ("KPI History & Trends"). See the interface for the
|
|
/// contract; this class adds notes on the data-access strategy per method.
|
|
/// </summary>
|
|
public sealed class KpiHistoryRepository : IKpiHistoryRepository
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly ILogger<KpiHistoryRepository> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="KpiHistoryRepository"/> class.
|
|
/// </summary>
|
|
/// <param name="context">The EF Core database context.</param>
|
|
/// <param name="logger">
|
|
/// Optional logger; defaults to <see cref="NullLogger{T}"/> (mirrors
|
|
/// <c>SiteCallAuditRepository</c> — MS.DI resolves <see cref="ILogger{T}"/> automatically,
|
|
/// so no registration churn). Used to classify the self-healing failover fold-race.
|
|
/// </param>
|
|
public KpiHistoryRepository(
|
|
ScadaBridgeDbContext context, ILogger<KpiHistoryRepository>? logger = null)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
_logger = logger ?? NullLogger<KpiHistoryRepository>.Instance;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RecordSamplesAsync(
|
|
IReadOnlyCollection<KpiSample> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<KpiSeriesPoint>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<SeriesHourKey, KpiRollupHourly>(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.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<KpiSeriesPoint>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DateTime?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Truncates a UTC timestamp to the start of its hour (minutes/seconds/ticks zeroed).</summary>
|
|
private static DateTime TruncateToHour(DateTime utc) =>
|
|
new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>In-memory grouping key: one KPI series (four-tuple) within one UTC hour.</summary>
|
|
private readonly record struct SeriesHourKey(
|
|
string Source, string Metric, string Scope, string? ScopeKey, DateTime HourStartUtc);
|
|
|
|
/// <summary>Narrow, untracked projection of one KpiSample row for the fold (R2).</summary>
|
|
private readonly record struct FoldSample(
|
|
string Source, string Metric, string Scope, string? ScopeKey, DateTime CapturedAtUtc, double Value);
|
|
}
|