fix(site-call-audit): reconciliation pull serves only un-acked tracking rows (node-local ack ledger), batched page upsert, prune removed sites' cursors (review 2026-10-21 06/PERF-2 + lane H follow-up)
This commit is contained in:
+120
@@ -1057,4 +1057,124 @@ public class OperationTrackingStoreTests : IDisposable
|
||||
Assert.Equal(System.Data.ConnectionState.Closed, connection.State);
|
||||
}
|
||||
|
||||
|
||||
// ── Central-ack filter for the reconciliation pull (review 2026-10-21 06/PERF-2) ──
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a row whose current version central has acknowledged through the
|
||||
/// cached-telemetry push is no longer served by the reconciliation read, while
|
||||
/// unacknowledged rows still are.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadChangedSince_SkipsRowsCentralAckedAtTheirCurrentVersion()
|
||||
{
|
||||
var (store, _) = CreateStore(nameof(ReadChangedSince_SkipsRowsCentralAckedAtTheirCurrentVersion));
|
||||
await using var _store = store;
|
||||
var acked = TrackedOperationId.New();
|
||||
var unacked = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(acked, nameof(AuditKind.ApiCallCached), "A", null, null, "node-a");
|
||||
await store.RecordEnqueueAsync(unacked, nameof(AuditKind.ApiCallCached), "B", null, null, "node-a");
|
||||
var snapshot = await store.GetStatusAsync(acked);
|
||||
|
||||
await store.RecordCentralAcksAsync(new Dictionary<TrackedOperationId, DateTime>
|
||||
{
|
||||
[acked] = snapshot!.UpdatedAtUtc,
|
||||
});
|
||||
|
||||
var rows = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100);
|
||||
Assert.Equal(new[] { unacked }, rows.Select(r => r.TrackedOperationId).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a row changed AFTER central acknowledged it is served again — the ack
|
||||
/// covers one version, not the operation.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadChangedSince_ServesRowChangedAfterItsAck()
|
||||
{
|
||||
var (store, _) = CreateStore(nameof(ReadChangedSince_ServesRowChangedAfterItsAck));
|
||||
await using var _store = store;
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, nameof(AuditKind.ApiCallCached), "A", null, null, "node-a");
|
||||
var submitted = await store.GetStatusAsync(id);
|
||||
await store.RecordCentralAcksAsync(new Dictionary<TrackedOperationId, DateTime>
|
||||
{
|
||||
[id] = submitted!.UpdatedAtUtc,
|
||||
});
|
||||
await Task.Delay(5); // a strictly later UpdatedAtUtc stamp
|
||||
await store.RecordTerminalAsync(id, nameof(AuditStatus.Delivered), null, 200);
|
||||
|
||||
var rows = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100);
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(nameof(AuditStatus.Delivered), row.Status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an older ack never rolls back a newer one (acks are monotonic per row),
|
||||
/// and that the ack ledger is pruned with the tracking rows it describes.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CentralAcks_AreMonotonic_AndPrunedWithTheirTrackingRow()
|
||||
{
|
||||
var (store, dataSource) = CreateStore(nameof(CentralAcks_AreMonotonic_AndPrunedWithTheirTrackingRow));
|
||||
await using var _store = store;
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, nameof(AuditKind.ApiCallCached), "A", null, null, "node-a");
|
||||
await store.RecordTerminalAsync(id, nameof(AuditStatus.Delivered), null, 200);
|
||||
var terminal = await store.GetStatusAsync(id);
|
||||
|
||||
await store.RecordCentralAcksAsync(new Dictionary<TrackedOperationId, DateTime> { [id] = terminal!.UpdatedAtUtc });
|
||||
await store.RecordCentralAcksAsync(new Dictionary<TrackedOperationId, DateTime> { [id] = terminal.CreatedAtUtc.AddSeconds(-1) });
|
||||
Assert.Empty(await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100));
|
||||
|
||||
// Age the terminal row past retention and purge: the ack row goes with it.
|
||||
await store.FlushAsync();
|
||||
using (var connection = OpenVerifierConnection(dataSource))
|
||||
using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "UPDATE OperationTracking SET TerminalAtUtc = $old;";
|
||||
cmd.Parameters.AddWithValue("$old", DateTime.UtcNow.AddDays(-30).ToString("o"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
await store.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-7));
|
||||
|
||||
using (var connection = OpenVerifierConnection(dataSource))
|
||||
using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM OperationTrackingCentralAck;";
|
||||
Assert.Equal(0L, (long)cmd.ExecuteScalar()!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the ack ledger is its own table, not a column on the LocalDb-replicated
|
||||
/// <c>OperationTracking</c> table (whose capture triggers are built from its column list).
|
||||
/// Being node-local, a standby that takes over has no ack rows and serves every row once.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CentralAckLedger_IsADistinctNodeLocalTable_NotAColumnOnTheReplicatedTable()
|
||||
{
|
||||
var (store, dataSource) = CreateStore(nameof(CentralAckLedger_IsADistinctNodeLocalTable_NotAColumnOnTheReplicatedTable));
|
||||
await using var _store = store;
|
||||
await store.FlushAsync();
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA table_info(OperationTracking);";
|
||||
var columns = new List<string>();
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
{
|
||||
while (reader.Read()) columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(columns, c => c.StartsWith("Acked", StringComparison.Ordinal)
|
||||
|| c.Contains("CentralAck", StringComparison.Ordinal));
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'OperationTrackingCentralAck';";
|
||||
Assert.Equal(1L, (long)cmd.ExecuteScalar()!);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user