chore(data-layer): low-severity cleanups — SQLite param chunking, KPI metric catalog, computed-column predicate guard
This commit is contained in:
@@ -55,6 +55,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
// transaction and never reached once audit_event's insert throws).
|
// transaction and never reached once audit_event's insert throws).
|
||||||
private const int SqliteErrorConstraint = 19;
|
private const int SqliteErrorConstraint = 19;
|
||||||
|
|
||||||
|
// Max EventIds bound into a single IN (...) UPDATE by MarkForwardedAsync /
|
||||||
|
// MarkReconciledAsync. SQLite caps bound parameters per statement (999 on older
|
||||||
|
// builds, SQLITE_MAX_VARIABLE_NUMBER); a large drain batch would otherwise blow the
|
||||||
|
// ceiling as batch sizes grow. Chunk the id list into ≤500-id slices, all inside one
|
||||||
|
// transaction so the batch stays atomic (arch-review 04, P8).
|
||||||
|
private const int MarkForwardStateChunkSize = 500;
|
||||||
|
|
||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
// Dedicated read-only connection used by GetBacklogStatsAsync,
|
// Dedicated read-only connection used by GetBacklogStatsAsync,
|
||||||
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
|
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
|
||||||
@@ -625,13 +632,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
using var cmd = _connection.CreateCommand();
|
|
||||||
// Flip the sidecar — UPDATE audit_forward_state, not the canonical
|
// Flip the sidecar — UPDATE audit_forward_state, not the canonical
|
||||||
// audit_event (which is append-only / write-once). Bump AttemptCount +
|
// audit_event (which is append-only / write-once). Bump AttemptCount +
|
||||||
// stamp LastAttemptUtc so operators can see how many drain passes a row
|
// stamp LastAttemptUtc so operators can see how many drain passes a row
|
||||||
// took to forward. Build a single IN (...) parameter list so we issue
|
// took to forward. Each id is bound as its own parameter, so no string
|
||||||
// one UPDATE per batch regardless of size. Each id is bound as its own
|
// concatenation of user data ever enters the SQL.
|
||||||
// parameter, so no string concatenation of user data ever enters the SQL.
|
|
||||||
//
|
//
|
||||||
// Defensive state guard: only transition rows that are still Pending or
|
// Defensive state guard: only transition rows that are still Pending or
|
||||||
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
|
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
|
||||||
@@ -641,25 +646,41 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
// WHERE ForwardState IN ($pending, $forwarded)
|
// WHERE ForwardState IN ($pending, $forwarded)
|
||||||
// guard. Current callers only pass Pending IDs, so normal-path behaviour
|
// guard. Current callers only pass Pending IDs, so normal-path behaviour
|
||||||
// is unchanged; the guard is purely defensive.
|
// is unchanged; the guard is purely defensive.
|
||||||
var sb = new System.Text.StringBuilder();
|
//
|
||||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
|
// Stamp one $now for the whole call so every chunk records the same
|
||||||
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
|
// LastAttemptUtc (matching the prior single-statement behaviour).
|
||||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
var now = DateTime.UtcNow.ToString(
|
||||||
for (int i = 0; i < eventIds.Count; i++)
|
"o", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
{
|
var forwarded = AuditForwardState.Forwarded.ToString();
|
||||||
if (i > 0) sb.Append(',');
|
var pending = AuditForwardState.Pending.ToString();
|
||||||
var p = $"$id{i}";
|
|
||||||
sb.Append(p);
|
|
||||||
cmd.Parameters.AddWithValue(p, eventIds[i].ToString());
|
|
||||||
}
|
|
||||||
sb.Append(");");
|
|
||||||
cmd.CommandText = sb.ToString();
|
|
||||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
|
||||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
|
||||||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString(
|
|
||||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
cmd.ExecuteNonQuery();
|
// Chunk into ≤MarkForwardStateChunkSize-id UPDATEs, all inside ONE
|
||||||
|
// transaction so the whole batch commits atomically (arch-review 04, P8).
|
||||||
|
using var transaction = _connection.BeginTransaction();
|
||||||
|
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
|
||||||
|
{
|
||||||
|
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.Transaction = transaction;
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
|
||||||
|
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
|
||||||
|
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||||
|
for (int i = 0; i < chunk; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
var p = $"$id{i}";
|
||||||
|
sb.Append(p);
|
||||||
|
cmd.Parameters.AddWithValue(p, eventIds[start + i].ToString());
|
||||||
|
}
|
||||||
|
sb.Append(");");
|
||||||
|
cmd.CommandText = sb.ToString();
|
||||||
|
cmd.Parameters.AddWithValue("$forwarded", forwarded);
|
||||||
|
cmd.Parameters.AddWithValue("$pending", pending);
|
||||||
|
cmd.Parameters.AddWithValue("$now", now);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
transaction.Commit();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -728,27 +749,39 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
using var cmd = _connection.CreateCommand();
|
|
||||||
// Flip the sidecar from Pending/Forwarded → Reconciled. Rows
|
// Flip the sidecar from Pending/Forwarded → Reconciled. Rows
|
||||||
// already Reconciled are left untouched (idempotent re-call), and the
|
// already Reconciled are left untouched (idempotent re-call), and the
|
||||||
// canonical audit_event row is never modified.
|
// canonical audit_event row is never modified.
|
||||||
var sb = new System.Text.StringBuilder();
|
var reconciled = AuditForwardState.Reconciled.ToString();
|
||||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
|
var pending = AuditForwardState.Pending.ToString();
|
||||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
var forwarded = AuditForwardState.Forwarded.ToString();
|
||||||
for (int i = 0; i < eventIds.Count; i++)
|
|
||||||
{
|
|
||||||
if (i > 0) sb.Append(',');
|
|
||||||
var p = $"$id{i}";
|
|
||||||
sb.Append(p);
|
|
||||||
cmd.Parameters.AddWithValue(p, eventIds[i].ToString());
|
|
||||||
}
|
|
||||||
sb.Append(");");
|
|
||||||
cmd.CommandText = sb.ToString();
|
|
||||||
cmd.Parameters.AddWithValue("$reconciled", AuditForwardState.Reconciled.ToString());
|
|
||||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
|
||||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
|
||||||
|
|
||||||
cmd.ExecuteNonQuery();
|
// Chunk into ≤MarkForwardStateChunkSize-id UPDATEs, all inside ONE
|
||||||
|
// transaction so the whole batch commits atomically (arch-review 04, P8).
|
||||||
|
using var transaction = _connection.BeginTransaction();
|
||||||
|
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
|
||||||
|
{
|
||||||
|
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.Transaction = transaction;
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
|
||||||
|
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||||
|
for (int i = 0; i < chunk; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
var p = $"$id{i}";
|
||||||
|
sb.Append(p);
|
||||||
|
cmd.Parameters.AddWithValue(p, eventIds[start + i].ToString());
|
||||||
|
}
|
||||||
|
sb.Append(");");
|
||||||
|
cmd.CommandText = sb.ToString();
|
||||||
|
cmd.Parameters.AddWithValue("$reconciled", reconciled);
|
||||||
|
cmd.Parameters.AddWithValue("$pending", pending);
|
||||||
|
cmd.Parameters.AddWithValue("$forwarded", forwarded);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
transaction.Commit();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ public static class KpiMetrics
|
|||||||
/// <summary>Pending-queue depth (rows awaiting delivery).</summary>
|
/// <summary>Pending-queue depth (rows awaiting delivery).</summary>
|
||||||
public const string QueueDepth = "queueDepth";
|
public const string QueueDepth = "queueDepth";
|
||||||
|
|
||||||
|
/// <summary>Stuck-row count (non-terminal older than the stuck-age threshold).</summary>
|
||||||
|
public const string StuckCount = "stuckCount";
|
||||||
|
|
||||||
/// <summary>Parked-row count.</summary>
|
/// <summary>Parked-row count.</summary>
|
||||||
public const string ParkedCount = "parkedCount";
|
public const string ParkedCount = "parkedCount";
|
||||||
|
|
||||||
/// <summary>Rows delivered in the last KPI interval.</summary>
|
/// <summary>Rows delivered in the last KPI interval.</summary>
|
||||||
public const string DeliveredLastInterval = "deliveredLastInterval";
|
public const string DeliveredLastInterval = "deliveredLastInterval";
|
||||||
|
|
||||||
|
/// <summary>Age of the oldest pending row, in seconds.</summary>
|
||||||
|
public const string OldestPendingAgeSeconds = "oldestPendingAgeSeconds";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+6
@@ -155,6 +155,12 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
|||||||
// rejected at CREATE. It is not indexed, so non-persistence costs nothing.
|
// rejected at CREATE. It is not indexed, so non-persistence costs nothing.
|
||||||
// Routed through the nullable UTC converter so the materialised value
|
// Routed through the nullable UTC converter so the materialised value
|
||||||
// carries Kind=Utc.
|
// carries Kind=Utc.
|
||||||
|
//
|
||||||
|
// NEVER use IngestedAtUtc in a WHERE predicate — it is non-persisted
|
||||||
|
// (stored:false), so any filter on it forces a full-table JSON_VALUE parse of
|
||||||
|
// DetailsJson on every row (no index can back it). Read-side projection only
|
||||||
|
// (arch-review 04, P7). Filter on OccurredAtUtc (the partition-aligned clustered
|
||||||
|
// key) instead.
|
||||||
builder.Property(e => e.IngestedAtUtc)
|
builder.Property(e => e.IngestedAtUtc)
|
||||||
.HasColumnType("datetime2(7)")
|
.HasColumnType("datetime2(7)")
|
||||||
.HasConversion(NullableUtcConverter)
|
.HasConversion(NullableUtcConverter)
|
||||||
|
|||||||
+2
-2
@@ -37,10 +37,10 @@ public sealed class NotificationOutboxKpiSampleSource : IKpiSampleSource
|
|||||||
// Charted metrics share the public Commons catalog so source + UI trend page key
|
// Charted metrics share the public Commons catalog so source + UI trend page key
|
||||||
// off one symbol. The uncharted internal metrics stay private here.
|
// off one symbol. The uncharted internal metrics stay private here.
|
||||||
private const string MetricQueueDepth = KpiMetrics.NotificationOutbox.QueueDepth;
|
private const string MetricQueueDepth = KpiMetrics.NotificationOutbox.QueueDepth;
|
||||||
private const string MetricStuckCount = "stuckCount";
|
private const string MetricStuckCount = KpiMetrics.NotificationOutbox.StuckCount;
|
||||||
private const string MetricParkedCount = KpiMetrics.NotificationOutbox.ParkedCount;
|
private const string MetricParkedCount = KpiMetrics.NotificationOutbox.ParkedCount;
|
||||||
private const string MetricDeliveredLastInterval = KpiMetrics.NotificationOutbox.DeliveredLastInterval;
|
private const string MetricDeliveredLastInterval = KpiMetrics.NotificationOutbox.DeliveredLastInterval;
|
||||||
private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds";
|
private const string MetricOldestPendingAgeSeconds = KpiMetrics.NotificationOutbox.OldestPendingAgeSeconds;
|
||||||
|
|
||||||
private readonly INotificationOutboxRepository _repository;
|
private readonly INotificationOutboxRepository _repository;
|
||||||
private readonly NotificationOutboxOptions _options;
|
private readonly NotificationOutboxOptions _options;
|
||||||
|
|||||||
@@ -590,6 +590,44 @@ public class SqliteAuditWriterWriteTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P8 (arch-review 04): a drain batch larger than SQLite's bound-parameter ceiling
|
||||||
|
/// (999 on older builds) must still flip every row. The writer chunks the id list
|
||||||
|
/// into ≤500-id UPDATEs inside one transaction, so 1200 ids in a single
|
||||||
|
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
|
||||||
|
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
|
||||||
|
{
|
||||||
|
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip));
|
||||||
|
await using var _ = writer;
|
||||||
|
|
||||||
|
const int count = 1200; // > 999 and > the 500 chunk size, so multiple chunks fire
|
||||||
|
var ids = new List<Guid>(count);
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
ids.Add(id);
|
||||||
|
await writer.WriteAsync(NewEvent(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One MarkForwardedAsync call over all 1200 ids — must not throw on the
|
||||||
|
// parameter ceiling and must flip every sidecar row to Forwarded.
|
||||||
|
await writer.MarkForwardedAsync(ids);
|
||||||
|
|
||||||
|
var afterForward = ForwardStateCounts(dataSource);
|
||||||
|
Assert.Equal(count, afterForward[AuditForwardState.Forwarded.ToString()]);
|
||||||
|
Assert.False(afterForward.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||||
|
|
||||||
|
// Same for reconciliation over the full batch.
|
||||||
|
await writer.MarkReconciledAsync(ids);
|
||||||
|
|
||||||
|
var afterReconcile = ForwardStateCounts(dataSource);
|
||||||
|
Assert.Equal(count, afterReconcile[AuditForwardState.Reconciled.ToString()]);
|
||||||
|
Assert.False(afterReconcile.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+14
@@ -33,6 +33,20 @@ public class NotificationOutboxKpiSampleSourceTests
|
|||||||
Assert.Equal(KpiSources.NotificationOutbox, source.Source);
|
Assert.Equal(KpiSources.NotificationOutbox, source.Source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C4 (arch-review 04): the two metric names that were inline string literals now
|
||||||
|
// live in the shared KpiMetrics catalog. Their VALUES are persisted in the KpiSample
|
||||||
|
// store and rendered by the trend UI, so a rename would silently orphan history —
|
||||||
|
// pin the exact historical strings here.
|
||||||
|
[Fact]
|
||||||
|
public void KpiMetricCatalog_NotificationOutbox_PreservesHistoricalStringValues()
|
||||||
|
{
|
||||||
|
Assert.Equal("queueDepth", KpiMetrics.NotificationOutbox.QueueDepth);
|
||||||
|
Assert.Equal("stuckCount", KpiMetrics.NotificationOutbox.StuckCount);
|
||||||
|
Assert.Equal("parkedCount", KpiMetrics.NotificationOutbox.ParkedCount);
|
||||||
|
Assert.Equal("deliveredLastInterval", KpiMetrics.NotificationOutbox.DeliveredLastInterval);
|
||||||
|
Assert.Equal("oldestPendingAgeSeconds", KpiMetrics.NotificationOutbox.OldestPendingAgeSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
|
public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user