perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene
This commit is contained in:
+74
-7
@@ -140,7 +140,14 @@ VALUES
|
||||
public async Task<IReadOnlyList<Notification>> GetDueAsync(
|
||||
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// AsNoTracking (WP2.2): the dispatcher mutates each row's delivery state in
|
||||
// memory and persists it through UpdateAsync, which is now a targeted
|
||||
// server-side ExecuteUpdate and needs no change tracker. Tracking a batch
|
||||
// of notifications — each carrying an nvarchar(max) Body and TypeData —
|
||||
// paid for a full snapshot copy per row plus a DetectChanges scan of the
|
||||
// whole batch on every save.
|
||||
return await _context.Notifications
|
||||
.AsNoTracking()
|
||||
.Where(n => n.Status == NotificationStatus.Pending
|
||||
|| (n.Status == NotificationStatus.Retrying
|
||||
&& n.NextAttemptAt != null
|
||||
@@ -150,22 +157,59 @@ VALUES
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Notifications.Update(n);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
ArgumentNullException.ThrowIfNull(n);
|
||||
|
||||
// Targeted server-side UPDATE of the seven mutable delivery-state columns
|
||||
// (WP2.2). The predecessor called DbSet.Update(n) + SaveChanges, which
|
||||
// marks EVERY property modified and rewrites all 21 columns — including
|
||||
// the immutable nvarchar(max) Body/TypeData payloads — on every single
|
||||
// delivery attempt. ExecuteUpdate also bypasses the change tracker
|
||||
// entirely, so it composes with the untracked GetDueAsync read.
|
||||
//
|
||||
// Immutable-by-contract columns (NotificationId, Type, ListName, Subject,
|
||||
// Body, TypeData, Source*, Origin*, SiteEnqueuedAt, CreatedAt) are
|
||||
// deliberately absent — see the interface contract: nothing in the
|
||||
// notification lifecycle ever changes them, and omitting them is what
|
||||
// makes the write narrow.
|
||||
var status = n.Status;
|
||||
var retryCount = n.RetryCount;
|
||||
var lastError = n.LastError;
|
||||
var resolvedTargets = n.ResolvedTargets;
|
||||
var lastAttemptAt = n.LastAttemptAt;
|
||||
var nextAttemptAt = n.NextAttemptAt;
|
||||
var deliveredAt = n.DeliveredAt;
|
||||
|
||||
var notificationId = n.NotificationId;
|
||||
|
||||
await _context.Notifications
|
||||
.Where(row => row.NotificationId == notificationId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(row => row.Status, status)
|
||||
.SetProperty(row => row.RetryCount, retryCount)
|
||||
.SetProperty(row => row.LastError, lastError)
|
||||
.SetProperty(row => row.ResolvedTargets, resolvedTargets)
|
||||
.SetProperty(row => row.LastAttemptAt, lastAttemptAt)
|
||||
.SetProperty(row => row.NextAttemptAt, nextAttemptAt)
|
||||
.SetProperty(row => row.DeliveredAt, deliveredAt),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
||||
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Notifications.AsQueryable();
|
||||
// AsNoTracking (WP2.2): this is the Central UI's read-only list page. The
|
||||
// rows are projected to the wire and never saved, so tracking them cost a
|
||||
// snapshot copy of every nvarchar(max) Body in the page for nothing.
|
||||
var query = _context.Notifications.AsNoTracking().AsQueryable();
|
||||
|
||||
if (filter.Status is { } status)
|
||||
{
|
||||
@@ -218,8 +262,14 @@ VALUES
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// NotificationId breaks CreatedAt ties so the OFFSET window is deterministic —
|
||||
// without it two rows sharing a CreatedAt could appear on both pages or on
|
||||
// neither. (This page keeps OFFSET paging rather than the sibling repos'
|
||||
// keyset cursor because its contract surfaces a page number and a total
|
||||
// count, neither of which a keyset cursor can express.)
|
||||
var rows = await query
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.ThenByDescending(n => n.NotificationId)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -244,7 +294,24 @@ VALUES
|
||||
// One conditional-aggregation pass replaces four sequential COUNT round trips:
|
||||
// each metric is a COUNT(CASE WHEN <predicate> THEN 1 END) over the same scan
|
||||
// (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group).
|
||||
//
|
||||
// WP2.2 — the aggregation is now PREDICATE-RESTRICTED instead of scanning the
|
||||
// whole table. Every KPI here is about the live queue (Pending/Retrying), the
|
||||
// parked backlog, or the last delivery interval; the overwhelming bulk of the
|
||||
// Notifications table is historical Delivered and Discarded rows that
|
||||
// contribute to NONE of them. The pre-filter below is the union of the
|
||||
// metric-contributing predicates, which lets the optimizer seek the status
|
||||
// index for the live/parked legs and the filtered
|
||||
// (DeliveredAt) WHERE Status='Delivered' index (WP1.4) for the interval leg,
|
||||
// instead of paying a full scan whose cost grows with retained history.
|
||||
// Mirrors the pre-filter ComputePerSiteKpisAsync/ComputePerNodeKpisAsync
|
||||
// already use — the global snapshot was the odd one out.
|
||||
var counts = 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(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user