perf(notification-outbox): single-query KPI aggregation; oldest-pending no longer materializes the live queue
This commit is contained in:
+108
-153
@@ -241,45 +241,40 @@ VALUES
|
|||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
var queueDepth = await _context.Notifications
|
// One conditional-aggregation pass replaces four sequential COUNT round trips:
|
||||||
.CountAsync(n => n.Status == NotificationStatus.Pending
|
// each metric is a COUNT(CASE WHEN <predicate> THEN 1 END) over the same scan
|
||||||
|| n.Status == NotificationStatus.Retrying, cancellationToken);
|
// (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group).
|
||||||
|
var counts = await _context.Notifications
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying),
|
||||||
|
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
&& n.CreatedAt < stuckCutoff),
|
||||||
|
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
|
||||||
|
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
|
||||||
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
var stuckCount = await _context.Notifications
|
// Oldest non-terminal CreatedAt — TOP(1) via ordered FirstOrDefault, so the
|
||||||
.CountAsync(n => (n.Status == NotificationStatus.Pending
|
// live queue is never materialized. The nullable cast lets FirstOrDefault
|
||||||
|| n.Status == NotificationStatus.Retrying)
|
// return null (no non-terminal rows) instead of throwing.
|
||||||
&& n.CreatedAt < stuckCutoff, cancellationToken);
|
var oldestCreatedAt = await _context.Notifications
|
||||||
|
|
||||||
var parkedCount = await _context.Notifications
|
|
||||||
.CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken);
|
|
||||||
|
|
||||||
var deliveredLastInterval = await _context.Notifications
|
|
||||||
.CountAsync(n => n.Status == NotificationStatus.Delivered
|
|
||||||
&& n.DeliveredAt != null
|
|
||||||
&& n.DeliveredAt >= deliveredSince, cancellationToken);
|
|
||||||
|
|
||||||
// Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
|
|
||||||
// Min aggregate awkward, so order ascending and take the first instead.
|
|
||||||
var nonTerminal = _context.Notifications
|
|
||||||
.Where(n => n.Status == NotificationStatus.Pending
|
.Where(n => n.Status == NotificationStatus.Pending
|
||||||
|| n.Status == NotificationStatus.Retrying);
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
.OrderBy(n => n.CreatedAt)
|
||||||
TimeSpan? oldestPendingAge = null;
|
.Select(n => (DateTimeOffset?)n.CreatedAt)
|
||||||
if (await nonTerminal.AnyAsync(cancellationToken))
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
{
|
|
||||||
var oldestCreatedAt = await nonTerminal
|
|
||||||
.OrderBy(n => n.CreatedAt)
|
|
||||||
.Select(n => n.CreatedAt)
|
|
||||||
.FirstAsync(cancellationToken);
|
|
||||||
oldestPendingAge = now - oldestCreatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NotificationKpiSnapshot(
|
return new NotificationKpiSnapshot(
|
||||||
QueueDepth: queueDepth,
|
QueueDepth: counts?.QueueDepth ?? 0,
|
||||||
StuckCount: stuckCount,
|
StuckCount: counts?.StuckCount ?? 0,
|
||||||
ParkedCount: parkedCount,
|
ParkedCount: counts?.ParkedCount ?? 0,
|
||||||
DeliveredLastInterval: deliveredLastInterval,
|
DeliveredLastInterval: counts?.DeliveredLastInterval ?? 0,
|
||||||
OldestPendingAge: oldestPendingAge);
|
OldestPendingAge: oldestCreatedAt is null ? null : now - oldestCreatedAt.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -288,49 +283,47 @@ VALUES
|
|||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
var queueDepth = await CountBySiteAsync(
|
// One grouped conditional-aggregation query per scope: counts AND the oldest
|
||||||
n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
|
// non-terminal CreatedAt (server-side MIN over a CASE, so the live queue is
|
||||||
cancellationToken);
|
// never materialized) computed in a single scan. Pre-filter to rows that
|
||||||
|
// contribute to at least one metric so a site with only fully-uncounted rows
|
||||||
|
// (e.g. Discarded, or out-of-window Delivered) does not surface as an
|
||||||
|
// all-zero row — preserving the prior union-of-metric-keys semantics.
|
||||||
|
var rows = await _context.Notifications
|
||||||
|
.Where(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying
|
||||||
|
|| n.Status == NotificationStatus.Parked
|
||||||
|
|| (n.Status == NotificationStatus.Delivered
|
||||||
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))
|
||||||
|
.GroupBy(n => n.SourceSiteId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Site = g.Key,
|
||||||
|
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying),
|
||||||
|
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
&& n.CreatedAt < stuckCutoff),
|
||||||
|
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
|
||||||
|
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
|
||||||
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
|
||||||
|
OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
? (DateTimeOffset?)n.CreatedAt
|
||||||
|
: null),
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var stuck = await CountBySiteAsync(
|
return rows
|
||||||
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
.OrderBy(r => r.Site, StringComparer.Ordinal)
|
||||||
&& n.CreatedAt < stuckCutoff,
|
.Select(r => new SiteNotificationKpiSnapshot(
|
||||||
cancellationToken);
|
SourceSiteId: r.Site,
|
||||||
|
QueueDepth: r.QueueDepth,
|
||||||
var parked = await CountBySiteAsync(
|
StuckCount: r.StuckCount,
|
||||||
n => n.Status == NotificationStatus.Parked, cancellationToken);
|
ParkedCount: r.ParkedCount,
|
||||||
|
DeliveredLastInterval: r.DeliveredLastInterval,
|
||||||
var delivered = await CountBySiteAsync(
|
OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
|
||||||
n => n.Status == NotificationStatus.Delivered
|
.ToList();
|
||||||
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
|
|
||||||
// converter is awkward (see ComputeKpisAsync), so project the non-terminal
|
|
||||||
// (site, created) pairs — the live queue, which stays bounded — and reduce
|
|
||||||
// in memory.
|
|
||||||
var oldest = (await _context.Notifications
|
|
||||||
.Where(n => n.Status == NotificationStatus.Pending
|
|
||||||
|| n.Status == NotificationStatus.Retrying)
|
|
||||||
.Select(n => new { n.SourceSiteId, n.CreatedAt })
|
|
||||||
.ToListAsync(cancellationToken))
|
|
||||||
.GroupBy(x => x.SourceSiteId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
|
|
||||||
|
|
||||||
var siteIds = queueDepth.Keys
|
|
||||||
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(s => s, StringComparer.Ordinal);
|
|
||||||
|
|
||||||
return siteIds.Select(site => new SiteNotificationKpiSnapshot(
|
|
||||||
SourceSiteId: site,
|
|
||||||
QueueDepth: queueDepth.GetValueOrDefault(site),
|
|
||||||
StuckCount: stuck.GetValueOrDefault(site),
|
|
||||||
ParkedCount: parked.GetValueOrDefault(site),
|
|
||||||
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
|
||||||
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
|
||||||
? now - createdAt
|
|
||||||
: null)).ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -339,83 +332,45 @@ VALUES
|
|||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
// Exclude rows with NULL SourceNode (legacy / unstamped) — per-node KPIs
|
// Same single-scan grouped aggregation as ComputePerSiteKpisAsync, keyed by
|
||||||
// are only meaningful when the node identity is known.
|
// SourceNode. Rows with a NULL SourceNode (legacy / unstamped) are excluded —
|
||||||
var queueDepth = await CountByNodeAsync(
|
// per-node KPIs are only meaningful when node identity is known.
|
||||||
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
var rows = await _context.Notifications
|
||||||
&& n.SourceNode != null,
|
.Where(n => n.SourceNode != null
|
||||||
cancellationToken);
|
&& (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying
|
||||||
var stuck = await CountByNodeAsync(
|
|| n.Status == NotificationStatus.Parked
|
||||||
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
|| (n.Status == NotificationStatus.Delivered
|
||||||
&& n.CreatedAt < stuckCutoff
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)))
|
||||||
&& n.SourceNode != null,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var parked = await CountByNodeAsync(
|
|
||||||
n => n.Status == NotificationStatus.Parked && n.SourceNode != null,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var delivered = await CountByNodeAsync(
|
|
||||||
n => n.Status == NotificationStatus.Delivered
|
|
||||||
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince
|
|
||||||
&& n.SourceNode != null,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Oldest non-terminal CreatedAt per node — same in-memory reduction
|
|
||||||
// pattern as ComputePerSiteKpisAsync (DateTimeOffset converter makes
|
|
||||||
// a SQL Min awkward).
|
|
||||||
var oldest = (await _context.Notifications
|
|
||||||
.Where(n => (n.Status == NotificationStatus.Pending
|
|
||||||
|| n.Status == NotificationStatus.Retrying)
|
|
||||||
&& n.SourceNode != null)
|
|
||||||
.Select(n => new { n.SourceNode, n.CreatedAt })
|
|
||||||
.ToListAsync(cancellationToken))
|
|
||||||
.GroupBy(x => x.SourceNode!)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
|
|
||||||
|
|
||||||
var nodeNames = queueDepth.Keys
|
|
||||||
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(n => n, StringComparer.Ordinal);
|
|
||||||
|
|
||||||
return nodeNames.Select(node => new NodeNotificationKpiSnapshot(
|
|
||||||
SourceNode: node,
|
|
||||||
QueueDepth: queueDepth.GetValueOrDefault(node),
|
|
||||||
StuckCount: stuck.GetValueOrDefault(node),
|
|
||||||
ParkedCount: parked.GetValueOrDefault(node),
|
|
||||||
DeliveredLastInterval: delivered.GetValueOrDefault(node),
|
|
||||||
OldestPendingAge: oldest.TryGetValue(node, out var createdAt)
|
|
||||||
? now - createdAt
|
|
||||||
: null)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
|
||||||
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
|
||||||
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await _context.Notifications
|
|
||||||
.Where(predicate)
|
|
||||||
.GroupBy(n => n.SourceSiteId)
|
|
||||||
.Select(g => new { Site = g.Key, Count = g.Count() })
|
|
||||||
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Counts notification rows matching <paramref name="predicate"/>, grouped by source node.
|
|
||||||
/// Only rows with a non-null <c>SourceNode</c> should be included; the predicate is
|
|
||||||
/// responsible for enforcing that guard.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<Dictionary<string, int>> CountByNodeAsync(
|
|
||||||
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await _context.Notifications
|
|
||||||
.Where(predicate)
|
|
||||||
.GroupBy(n => n.SourceNode!)
|
.GroupBy(n => n.SourceNode!)
|
||||||
.Select(g => new { Node = g.Key, Count = g.Count() })
|
.Select(g => new
|
||||||
.ToDictionaryAsync(x => x.Node, x => x.Count, cancellationToken);
|
{
|
||||||
|
Node = g.Key,
|
||||||
|
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying),
|
||||||
|
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
&& n.CreatedAt < stuckCutoff),
|
||||||
|
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
|
||||||
|
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
|
||||||
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
|
||||||
|
OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
? (DateTimeOffset?)n.CreatedAt
|
||||||
|
: null),
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.OrderBy(r => r.Node, StringComparer.Ordinal)
|
||||||
|
.Select(r => new NodeNotificationKpiSnapshot(
|
||||||
|
SourceNode: r.Node,
|
||||||
|
QueueDepth: r.QueueDepth,
|
||||||
|
StuckCount: r.StuckCount,
|
||||||
|
ParkedCount: r.ParkedCount,
|
||||||
|
DeliveredLastInterval: r.DeliveredLastInterval,
|
||||||
|
OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04,
|
||||||
|
/// Task 20). Each snapshot must be a single-query conditional aggregation with a
|
||||||
|
/// bounded oldest-pending — the old shape issued 5-7 sequential COUNT round trips
|
||||||
|
/// and materialized the whole live queue to find the oldest row.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationOutboxRepositoryKpiQueryShapeTests
|
||||||
|
{
|
||||||
|
private static Notification NewNotification(
|
||||||
|
string sourceSiteId, string? sourceNode, NotificationStatus status,
|
||||||
|
DateTimeOffset createdAt, DateTimeOffset? deliveredAt = null) =>
|
||||||
|
new(Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId)
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
DeliveredAt = deliveredAt,
|
||||||
|
SourceNode = sourceNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AllThreeKpiSnapshots_IssueAtMostTwoQueries_AndBoundTheOldestLookup()
|
||||||
|
{
|
||||||
|
var counter = new SelectCountingInterceptor();
|
||||||
|
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Three non-terminal rows for one site/node — the "live queue" the old code
|
||||||
|
// pulled into memory to find the oldest. All older than the stuck cutoff.
|
||||||
|
ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Pending, now.AddMinutes(-30)));
|
||||||
|
ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Retrying, now.AddMinutes(-20)));
|
||||||
|
ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Pending, now.AddMinutes(-10)));
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var repo = new NotificationOutboxRepository(ctx);
|
||||||
|
var stuckCutoff = now.AddMinutes(-5);
|
||||||
|
var deliveredSince = now.AddMinutes(-60);
|
||||||
|
|
||||||
|
// Global: one aggregation query + one bounded oldest query.
|
||||||
|
counter.Reset();
|
||||||
|
var global = await repo.ComputeKpisAsync(stuckCutoff, deliveredSince);
|
||||||
|
Assert.True(counter.Count <= 2,
|
||||||
|
$"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2");
|
||||||
|
// The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT.
|
||||||
|
Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Equal(3, global.QueueDepth);
|
||||||
|
Assert.NotNull(global.OldestPendingAge);
|
||||||
|
|
||||||
|
// Per-site: a single grouped conditional-aggregation query (counts + MIN).
|
||||||
|
counter.Reset();
|
||||||
|
var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince);
|
||||||
|
Assert.True(counter.Count <= 2,
|
||||||
|
$"ComputePerSiteKpisAsync issued {counter.Count} queries against Notifications; expected <= 2");
|
||||||
|
var siteA = Assert.Single(perSite);
|
||||||
|
Assert.Equal(3, siteA.QueueDepth);
|
||||||
|
Assert.NotNull(siteA.OldestPendingAge);
|
||||||
|
|
||||||
|
// Per-node: same single grouped aggregation, keyed by SourceNode.
|
||||||
|
counter.Reset();
|
||||||
|
var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince);
|
||||||
|
Assert.True(counter.Count <= 2,
|
||||||
|
$"ComputePerNodeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2");
|
||||||
|
var nodeA = Assert.Single(perNode);
|
||||||
|
Assert.Equal(3, nodeA.QueueDepth);
|
||||||
|
Assert.NotNull(nodeA.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the text of every reader command touching the Notifications table so a
|
||||||
|
/// test can count the round trips a KPI snapshot makes and inspect their shape.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SelectCountingInterceptor : DbCommandInterceptor
|
||||||
|
{
|
||||||
|
private readonly List<string> _commands = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Commands => _commands;
|
||||||
|
|
||||||
|
public int Count => _commands.Count;
|
||||||
|
|
||||||
|
public void Reset() => _commands.Clear();
|
||||||
|
|
||||||
|
private void Record(DbCommand command)
|
||||||
|
{
|
||||||
|
if (command.CommandText.Contains("Notifications", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_commands.Add(command.CommandText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override InterceptionResult<DbDataReader> ReaderExecuting(
|
||||||
|
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
|
||||||
|
{
|
||||||
|
Record(command);
|
||||||
|
return base.ReaderExecuting(command, eventData, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||||
|
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Record(command);
|
||||||
|
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user