using System.Data.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// /// M6 (K2) coverage for over the in-memory SQLite /// harness. Exercises the bulk write, the single-series query (including the /// null-ScopeKey Global match), and the retention purge. /// public class KpiHistoryRepositoryTests { // Fixed UTC base so the time assertions are deterministic. private static readonly DateTime Base = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); private static KpiSample Sample( string source, string metric, string scope, string? scopeKey, double value, DateTime capturedAtUtc) => new() { Source = source, Metric = metric, Scope = scope, ScopeKey = scopeKey, Value = value, CapturedAtUtc = capturedAtUtc, }; [Fact] public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // Two metrics ("queueDepth", "parkedCount") under one source; the target // series ("queueDepth", Global, null key) has two points at different // timestamps — inserted out of order to prove the OrderBy. await repo.RecordSamplesAsync(new[] { // Target series — Global / null ScopeKey, two points (later first). Sample("NotificationOutbox", "queueDepth", "Global", null, value: 7, capturedAtUtc: Base.AddMinutes(10)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(5)), // Different metric — must be excluded. Sample("NotificationOutbox", "parkedCount", "Global", null, value: 99, capturedAtUtc: Base.AddMinutes(5)), // Same metric but a Site scope with a non-null key — must be excluded // from the Global (null-key) query. Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", value: 42, capturedAtUtc: Base.AddMinutes(5)), }); var series = await repo.GetRawSeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddMinutes(60)); // Only the two Global/null-key queueDepth points, ascending by capture time. Assert.Equal(2, series.Count); Assert.Equal(Base.AddMinutes(5), series[0].BucketStartUtc); Assert.Equal(3, series[0].Value); Assert.Equal(Base.AddMinutes(10), series[1].BucketStartUtc); Assert.Equal(7, series[1].Value); } [Fact] public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("SiteCallAudit", "buffered", "Site", "plant-a", value: 5, capturedAtUtc: Base.AddMinutes(5)), Sample("SiteCallAudit", "buffered", "Site", "plant-b", value: 8, capturedAtUtc: Base.AddMinutes(5)), // A Global (null-key) row with the same metric must NOT leak into a // site-keyed query. Sample("SiteCallAudit", "buffered", "Global", null, value: 13, capturedAtUtc: Base.AddMinutes(5)), }); var series = await repo.GetRawSeriesAsync( "SiteCallAudit", "buffered", "Site", scopeKey: "plant-a", fromUtc: Base, toUtc: Base.AddMinutes(60)); Assert.Single(series); Assert.Equal(5, series[0].Value); } [Fact] public async Task GetRawSeriesAsync_HonorsTimeWindowBounds() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("Health", "ageSeconds", "Global", null, value: 1, capturedAtUtc: Base), Sample("Health", "ageSeconds", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(30)), // Outside the [from, to] window — excluded. Sample("Health", "ageSeconds", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(120)), }); var series = await repo.GetRawSeriesAsync( "Health", "ageSeconds", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddMinutes(60)); Assert.Equal(2, series.Count); Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray()); } [Fact] public async Task RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // Empty batch must not throw (early-return guard, no SaveChanges round-trip). await repo.RecordSamplesAsync(Array.Empty()); // With nothing written, GetRawSeriesAsync must return a non-null empty list. var series = await repo.GetRawSeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddMinutes(60)); Assert.NotNull(series); Assert.Empty(series); } [Fact] public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { // Two rows strictly older than the cutoff — should be purged. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-10)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-8)), // One row AT the cutoff — strictly-older predicate keeps it. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-7)), // One row newer than the cutoff — kept. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)), }); var cutoff = Base.AddDays(-7); var deleted = await repo.PurgeOlderThanAsync(cutoff); Assert.Equal(2, deleted); var remaining = await repo.GetRawSeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30)); // The cutoff row (==) and the newer row survive. Assert.Equal(2, remaining.Count); Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } [Fact] public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal() { // Rows span several days, each in its own hour-bucket, all older than the // cutoff. The batched purge must slice them into per-hour DELETE windows // (arch-review 04, P4) rather than one giant DELETE — so more than one // DELETE statement is issued, and the return value still equals the total. var counter = new DeleteCountingInterceptor(); await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { // Three distinct hour-buckets strictly older than the cutoff — purged. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-5)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-4)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-3)), // Newer than the cutoff — kept, and never scanned into a delete window. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 5, capturedAtUtc: Base), }); // Only count DELETEs the purge itself issues (RecordSamples above emits none). counter.Reset(); var deleted = await repo.PurgeOlderThanAsync(Base.AddDays(-2)); // All three old rows gone; the batched total is the sum across windows. Assert.Equal(3, deleted); Assert.True(counter.DeleteCount > 1, $"expected the batched purge to issue more than one DELETE, got {counter.DeleteCount}"); var remaining = await repo.GetRawSeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30)); Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray()); } // ---- Hourly rollup fold / query / purge (plan #22 T3) ------------------------------ [Fact] public async Task FoldHourlyRollupsAsync_GaugeMetric_FoldsToLastValuePerHour() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // "queueDepth" is a Gauge → last (latest-timestamp) value in the hour wins. await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(5)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 9, capturedAtUtc: Base.AddMinutes(59)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 7, capturedAtUtc: Base.AddMinutes(30)), }); // toHourUtc = Base + 1 h (next hour start) → the Base hour is complete and folded. await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); var series = await repo.GetHourlySeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddHours(24)); var row = Assert.Single(series); Assert.Equal(Base, row.BucketStartUtc); Assert.Equal(9, row.Value); // last-value (max CapturedAtUtc) } [Fact] public async Task FoldHourlyRollupsAsync_RateMetric_FoldsToSumPerHour_WithMinMaxCount() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // "deliveredLastInterval" is a Rate → sum of the hour's per-interval deltas. await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "deliveredLastInterval", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(5)), Sample("NotificationOutbox", "deliveredLastInterval", "Global", null, value: 5, capturedAtUtc: Base.AddMinutes(25)), Sample("NotificationOutbox", "deliveredLastInterval", "Global", null, value: 4, capturedAtUtc: Base.AddMinutes(45)), }); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); // Read the entity directly to assert Min/Max/Count fidelity, not just Value. var rollup = Assert.Single(await ctx.KpiRollupHourly.ToListAsync()); Assert.Equal(Base, rollup.HourStartUtc); Assert.Equal(11, rollup.Value); // sum 2+5+4 Assert.Equal(2, rollup.MinValue); Assert.Equal(5, rollup.MaxValue); Assert.Equal(3, rollup.SampleCount); } [Fact] public async Task FoldHourlyRollupsAsync_SplitsIntoSeparateHourBuckets_ForGlobalScope() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // Two hours of one Global (null ScopeKey) gauge series. await repo.RecordSamplesAsync(new[] { Sample("SiteHealth", "connectionsUp", "Global", null, value: 4, capturedAtUtc: Base.AddMinutes(10)), Sample("SiteHealth", "connectionsUp", "Global", null, value: 6, capturedAtUtc: Base.AddMinutes(50)), Sample("SiteHealth", "connectionsUp", "Global", null, value: 2, capturedAtUtc: Base.AddHours(1).AddMinutes(20)), }); // Fold two complete hours: [Base, Base+2h). await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2)); var series = await repo.GetHourlySeriesAsync( "SiteHealth", "connectionsUp", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddHours(24)); Assert.Equal(2, series.Count); Assert.Equal(Base, series[0].BucketStartUtc); Assert.Equal(6, series[0].Value); // hour 0 last-value Assert.Equal(Base.AddHours(1), series[1].BucketStartUtc); Assert.Equal(2, series[1].Value); // hour 1 last-value } [Fact] public async Task FoldHourlyRollupsAsync_ExcludesIncompleteHour_AtExclusiveUpperBound() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { // Complete hour (Base) — folded. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(30)), // Next, in-progress hour — must NOT be folded when toHourUtc == Base+1h. Sample("NotificationOutbox", "queueDepth", "Global", null, value: 99, capturedAtUtc: Base.AddHours(1).AddMinutes(15)), }); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); var series = await repo.GetHourlySeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddHours(24)); // Only the complete Base hour is present; the in-progress hour is excluded. var row = Assert.Single(series); Assert.Equal(Base, row.BucketStartUtc); Assert.Equal(3, row.Value); } [Fact] public async Task FoldHourlyRollupsAsync_IsIdempotent_AcrossRepeatedRunsOverSameWindow() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "deliveredLastInterval", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(5)), Sample("NotificationOutbox", "deliveredLastInterval", "Global", null, value: 5, capturedAtUtc: Base.AddMinutes(45)), }); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); // re-run same window // Exactly one row for the (series, hour); the rate sum is not double-counted. var rollups = await ctx.KpiRollupHourly.ToListAsync(); var row = Assert.Single(rollups); Assert.Equal(7, row.Value); // still 2+5, not 14 Assert.Equal(2, row.SampleCount); } [Fact] public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // Same metric, one Global (null key) hour and one Site-keyed hour, both foldable. await repo.RecordSamplesAsync(new[] { Sample("SiteCallAudit", "buffered", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(10)), Sample("SiteCallAudit", "buffered", "Global", null, value: 8, capturedAtUtc: Base.AddHours(1).AddMinutes(10)), Sample("SiteCallAudit", "buffered", "Site", "plant-a", value: 42, capturedAtUtc: Base.AddMinutes(10)), }); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2)); var global = await repo.GetHourlySeriesAsync( "SiteCallAudit", "buffered", "Global", scopeKey: null, fromUtc: Base, toUtc: Base.AddHours(24)); // Two Global hours, ascending; the Site-keyed row must not leak in. Assert.Equal(2, global.Count); Assert.Equal(Base, global[0].BucketStartUtc); Assert.Equal(3, global[0].Value); Assert.Equal(Base.AddHours(1), global[1].BucketStartUtc); Assert.Equal(8, global[1].Value); var site = await repo.GetHourlySeriesAsync( "SiteCallAudit", "buffered", "Site", scopeKey: "plant-a", fromUtc: Base, toUtc: Base.AddHours(24)); Assert.Equal(42, Assert.Single(site).Value); } [Fact] public async Task PurgeRollupsOlderThanAsync_DeletesOnlyRollupsOlderThanCutoff() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); // Fold four distinct hours spanning several days. await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-10).AddMinutes(5)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-8).AddMinutes(5)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-7).AddMinutes(5)), Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1).AddMinutes(5)), }); await repo.FoldHourlyRollupsAsync(Base.AddDays(-30), Base); // Cutoff = the -7d hour start; strictly-older predicate keeps the ==cutoff row. var cutoff = TruncateHour(Base.AddDays(-7)); await repo.PurgeRollupsOlderThanAsync(cutoff); var remaining = await repo.GetHourlySeriesAsync( "NotificationOutbox", "queueDepth", "Global", scopeKey: null, fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30)); // The -7d (==cutoff) and -1d rollups survive; the -10d and -8d are purged. Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } [Fact] public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), Sample("NotificationOutbox", "queueDepth", "Global", null, 7, Base.AddMinutes(50)), }); ctx.ChangeTracker.Clear(); // isolate the fold's tracking behavior from the seed await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); // The fold READS samples and WRITES rollups; the read must not register // thousands of read-only KpiSample entries for DetectChanges to re-scan // (arch-review 04 round 2, R2). Assert.Empty(ctx.ChangeTracker.Entries()); } [Fact] public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); Assert.Null(await repo.GetLatestRollupHourAsync()); } [Fact] public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries() { await using var ctx = NewContext(); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), Sample("SiteCallAudit", "buffered", "Global", null, 2, Base.AddHours(3).AddMinutes(10)), }); await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(4)); Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync()); } // ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ---- [Fact] public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals() { // A single shared in-memory SQLite connection so a second context (the interceptor's // side-write, and the "next tick" fold) sees the same database. await using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); await connection.OpenAsync(); await using (var schema = ContextOn(connection)) { await schema.Database.EnsureCreatedAsync(); } // The old singleton incarnation's in-flight fold wins the (series, hour) upsert race: // the interceptor inserts the conflicting rollup row on the same connection just before // THIS fold's SaveChanges, so the unique IX_KpiRollupHourly_Series faults the loser's save. // A non-null ScopeKey (site-scoped series) is used deliberately: SQLite treats NULLs as // distinct in a UNIQUE index, so only a non-null key deterministically forces the conflict. var interceptor = new InsertConflictingRollupOnFirstSave(connection, () => new KpiRollupHourly { Source = "NotificationOutbox", Metric = "queueDepth", Scope = "Site", ScopeKey = "plant-a", HourStartUtc = Base, Value = -1, MinValue = -1, MaxValue = -1, SampleCount = 1, }); await using var ctx = ContextOn(connection, interceptor); var repo = new KpiHistoryRepository(ctx); await repo.RecordSamplesAsync(new[] { Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", 5, Base.AddMinutes(10)), }); // Losing the race must NOT propagate DbUpdateException — the failure grain is the pass. var ex = await Record.ExceptionAsync(() => repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1))); Assert.Null(ex); // Next tick's fold (a fresh scope/context over the same DB) self-heals: it finds the // conflicting row and idempotently upserts it in place to the correct folded value. await using var ctx2 = ContextOn(connection); var repo2 = new KpiHistoryRepository(ctx2); await repo2.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); var series = await repo2.GetHourlySeriesAsync( "NotificationOutbox", "queueDepth", "Site", "plant-a", Base, Base.AddHours(1)); Assert.Single(series); Assert.Equal(5, series[0].Value); // the fold's own value overwrote the side-inserted -1 } // Local mirror of the repo's private hour-truncation, for cutoff assertions. private static DateTime TruncateHour(DateTime utc) => new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); /// Builds a SQLite test context bound to an externally-owned open connection /// (so multiple contexts share one in-memory database), with optional interceptors. private static ScadaBridgeDbContext ContextOn(DbConnection connection, params IInterceptor[] interceptors) { var options = new DbContextOptionsBuilder() .UseSqlite(connection) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .AddInterceptors(interceptors) .Options; return new SqliteTestDbContext(options); } /// /// SaveChanges interceptor that, exactly once and only when a fold is about to insert a /// , inserts a conflicting (series, hour) rollup row through a /// second context on the same connection — deterministically reproducing the failover-overlap /// upsert race the losing fold must survive (arch-review 04 round 2, R5). /// private sealed class InsertConflictingRollupOnFirstSave : Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor { private readonly DbConnection _connection; private readonly Func _rowFactory; private bool _fired; public InsertConflictingRollupOnFirstSave(DbConnection connection, Func rowFactory) { _connection = connection; _rowFactory = rowFactory; } public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (!_fired && eventData.Context is not null && eventData.Context.ChangeTracker.Entries() .Any(e => e.State == EntityState.Added)) { _fired = true; var options = new DbContextOptionsBuilder() .UseSqlite(_connection) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; await using var side = new SqliteTestDbContext(options); side.KpiRollupHourly.Add(_rowFactory()); await side.SaveChangesAsync(cancellationToken); } return await base.SavingChangesAsync(eventData, result, cancellationToken); } } /// /// EF command interceptor that counts the DELETE statements actually issued /// to the store, so a test can prove the purge is sliced into multiple /// windowed DELETEs rather than one mega-statement. Threads both the sync and /// async non-query entry points (ExecuteDeleteAsync routes through the /// async path). /// private sealed class DeleteCountingInterceptor : DbCommandInterceptor { public int DeleteCount { get; private set; } public void Reset() => DeleteCount = 0; private void CountIfDelete(DbCommand command) { if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase)) { DeleteCount++; } } public override InterceptionResult NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult result) { CountIfDelete(command); return base.NonQueryExecuting(command, eventData, result); } public override ValueTask> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { CountIfDelete(command); return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } } }