using Microsoft.EntityFrameworkCore; 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; /// /// Initializes a new instance of the class. /// /// The EF Core database context. public KpiHistoryRepository(ScadaBridgeDbContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } /// 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; } }