perf(sql): sweep/KPI covering indexes + sliced notification terminal purge
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
BEGIN TRANSACTION;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260814235335_AddAuditLogAndNotificationCoveringIndexes'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
EXEC(N'CREATE INDEX [IX_Notifications_Delivered] ON [Notifications] ([DeliveredAt]) WHERE [Status] = ''Delivered''');
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260814235335_AddAuditLogAndNotificationCoveringIndexes'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||||
|
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||||
|
INCLUDE (Status)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260814235335_AddAuditLogAndNotificationCoveringIndexes'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||||
|
VALUES (N'20260814235335_AddAuditLogAndNotificationCoveringIndexes', N'10.0.7');
|
||||||
|
END;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
GO
|
||||||
|
|
||||||
+6
-1
@@ -185,9 +185,14 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
|||||||
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
|
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
|
||||||
// preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
|
// preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
|
||||||
// off the computed columns.
|
// off the computed columns.
|
||||||
|
// INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status
|
||||||
|
// over a recent OccurredAtUtc range) a covering index seek — the persisted
|
||||||
|
// computed Status column rides along in the leaf without a key/RID lookup back
|
||||||
|
// into the clustered index per matching row (arch-review WP1.4).
|
||||||
builder.HasIndex(e => e.OccurredAtUtc)
|
builder.HasIndex(e => e.OccurredAtUtc)
|
||||||
.IsDescending(true)
|
.IsDescending(true)
|
||||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc")
|
||||||
|
.IncludeProperties(nameof(AuditLogRow.Status));
|
||||||
|
|
||||||
builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
|
builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
|
||||||
.IsDescending(false, true)
|
.IsDescending(false, true)
|
||||||
|
|||||||
+13
@@ -70,5 +70,18 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
|||||||
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
||||||
|
|
||||||
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
||||||
|
|
||||||
|
// Covers ComputeKpisAsync's "DeliveredLastInterval" count
|
||||||
|
// (Status == Delivered && DeliveredAt >= deliveredSince): filtered to just the
|
||||||
|
// Delivered rows so the index stays small relative to the full table and the KPI
|
||||||
|
// query can seek it directly instead of scanning every status (arch-review WP1.4).
|
||||||
|
// Status is stored as its enum name (HasConversion<string>() above), so the filter
|
||||||
|
// predicate matches on the literal 'Delivered', not the underlying int value.
|
||||||
|
// No N'' prefix: the value is ASCII-only (fine on SQL Server's nvarchar column)
|
||||||
|
// and the repository tests' SQLite provider rejects the T-SQL N'' national-string
|
||||||
|
// literal syntax outright when EnsureCreated() runs this filter as DDL.
|
||||||
|
builder.HasIndex(n => n.DeliveredAt)
|
||||||
|
.HasDatabaseName("IX_Notifications_Delivered")
|
||||||
|
.HasFilter("[Status] = 'Delivered'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2100
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Two covering indexes (arch-review WP1.4): <c>IX_AuditLog_OccurredAtUtc</c> gains
|
||||||
|
/// <c>INCLUDE (Status)</c> so the audit KPI window query (count/aggregate by Status
|
||||||
|
/// over a recent <c>OccurredAtUtc</c> range) can seek without a lookup back into the
|
||||||
|
/// clustered index per row; <c>IX_Notifications_Delivered</c> is a new filtered index
|
||||||
|
/// backing the notification outbox KPI's delivered-last-interval count.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>IX_AuditLog_OccurredAtUtc</c> is partition-aligned
|
||||||
|
/// (<c>ON ps_AuditLog_Month(OccurredAtUtc)</c>, see <c>CollapseAuditLogToCanonical</c>)
|
||||||
|
/// so the monthly partition-switch purge keeps touching a single partition. EF's
|
||||||
|
/// generic <c>DropIndex</c>/<c>CreateIndex</c> migration builders have no concept of
|
||||||
|
/// partition schemes and would silently rebuild the index off <c>[PRIMARY]</c> instead
|
||||||
|
/// of the scheme — this migration uses raw SQL for that index (mirroring
|
||||||
|
/// <c>CollapseAuditLogToCanonical</c>'s DDL) to preserve the alignment. The scaffolded
|
||||||
|
/// EF model itself carries no partition annotation either way (pre-existing gap, same
|
||||||
|
/// as every other AuditLog index) — only the physical DDL below matters.
|
||||||
|
/// <c>Notifications</c> is not partitioned, so its index uses the ordinary EF builder.
|
||||||
|
/// The filter predicate is a plain <c>'Delivered'</c> literal (no <c>N''</c> prefix):
|
||||||
|
/// the value is ASCII-only, and the ConfigurationDatabase repository tests run this
|
||||||
|
/// same filter as DDL against the SQLite provider via <c>EnsureCreated()</c>, which
|
||||||
|
/// rejects the T-SQL national-string-literal syntax outright.
|
||||||
|
/// </remarks>
|
||||||
|
public partial class AddAuditLogAndNotificationCoveringIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_Delivered",
|
||||||
|
table: "Notifications",
|
||||||
|
column: "DeliveredAt",
|
||||||
|
filter: "[Status] = 'Delivered'");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||||
|
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||||
|
INCLUDE (Status)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_Delivered",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||||
|
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -903,6 +903,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
|
|
||||||
b.HasKey("NotificationId");
|
b.HasKey("NotificationId");
|
||||||
|
|
||||||
|
b.HasIndex("DeliveredAt")
|
||||||
|
.HasDatabaseName("IX_Notifications_Delivered")
|
||||||
|
.HasFilter("[Status] = 'Delivered'");
|
||||||
|
|
||||||
b.HasIndex("SourceSiteId", "CreatedAt");
|
b.HasIndex("SourceSiteId", "CreatedAt");
|
||||||
|
|
||||||
b.HasIndex("Status", "NextAttemptAt");
|
b.HasIndex("Status", "NextAttemptAt");
|
||||||
@@ -1813,6 +1817,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
.IsDescending()
|
.IsDescending()
|
||||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||||
|
|
||||||
|
SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("OccurredAtUtc"), new[] { "Status" });
|
||||||
|
|
||||||
b.HasIndex("ParentExecutionId")
|
b.HasIndex("ParentExecutionId")
|
||||||
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -230,9 +230,30 @@ VALUES
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Notifications
|
// Time-sliced batches: each DELETE covers at most one hour of terminal rows,
|
||||||
|
// capping the lock/log footprint per statement — mirrors
|
||||||
|
// KpiHistoryRepository.PurgeOlderThanAsync (arch-review WP1.4; an unbounded
|
||||||
|
// single-statement ExecuteDeleteAsync would otherwise turn a long-idle catch-up
|
||||||
|
// of the default 365-day window into one giant transaction against
|
||||||
|
// dbo.Notifications). Pure EF LINQ (no raw SQL) so this stays portable across
|
||||||
|
// the SQL Server production provider and the SQLite provider the repository
|
||||||
|
// tests run against.
|
||||||
|
var total = 0;
|
||||||
|
var floor = await _context.Notifications
|
||||||
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
|
||||||
|
while (floor is not null && floor < cutoff)
|
||||||
|
{
|
||||||
|
var ceiling = floor.Value.AddHours(1) < cutoff ? floor.Value.AddHours(1) : cutoff;
|
||||||
|
total += await _context.Notifications
|
||||||
|
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < ceiling)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
floor = await _context.Notifications
|
||||||
|
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
||||||
|
.MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -94,6 +94,23 @@ public static class StoreAndForwardSchema
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms)";
|
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms)";
|
||||||
dueIndex.ExecuteNonQuery();
|
dueIndex.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Covering index for GetMessagesForRetryAsync's ORDER BY created_at ASC.
|
||||||
|
// idx_sf_messages_status_due (above) matches the status filter and the
|
||||||
|
// last_attempt_at_ms half of the due predicate, but its column order does
|
||||||
|
// NOT match the query's "ORDER BY created_at ASC", so SQLite still needs a
|
||||||
|
// sort/scan step to satisfy that ordering on a large due-sweep. This index's
|
||||||
|
// leading (status, created_at) column order lets the planner walk matching
|
||||||
|
// rows already in created_at order and stop at LIMIT without touching
|
||||||
|
// non-pending rows or re-sorting (arch-review WP1.4). idx_sf_messages_status_due
|
||||||
|
// is left in place — it still backs status+due-time lookups that don't order by
|
||||||
|
// created_at.
|
||||||
|
using (var orderIndex = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
orderIndex.CommandText =
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_created ON sf_messages(status, created_at)";
|
||||||
|
orderIndex.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user