perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene
This commit is contained in:
+36
-12
@@ -128,6 +128,37 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository
|
||||
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;
|
||||
@@ -143,18 +174,11 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository
|
||||
var maxValue = group.Max(s => s.Value);
|
||||
var sampleCount = group.Count();
|
||||
|
||||
// Idempotent upsert on the unique series+hour key. The ScopeKey == key.ScopeKey
|
||||
// comparison matches null against the Global-scope rows (IS NULL) exactly as the
|
||||
// UNIQUE IX_KpiRollupHourly_Series index treats a null key as participating.
|
||||
var existing = await _context.KpiRollupHourly.FirstOrDefaultAsync(
|
||||
r => r.Source == key.Source
|
||||
&& r.Metric == key.Metric
|
||||
&& r.Scope == key.Scope
|
||||
&& r.ScopeKey == key.ScopeKey
|
||||
&& r.HourStartUtc == key.HourStartUtc,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is null)
|
||||
// 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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user