feat(site-call-audit): additive (UpdatedAtUtc, TrackedOperationId) keyset in the reconciliation pull contract
Site side: additive proto after_id field, ReadChangedSinceAsync gains an optional afterId cursor and deterministic (UpdatedAtUtc, TrackedOperationId) ordering — a batch fully inside one UpdatedAtUtc instant no longer re-reads the same page forever. Absent/empty afterId preserves the exact legacy inclusive >= contract, so an older central is unaffected. Regenerated the checked-in gRPC C#.
This commit is contained in:
+92
-4
@@ -466,7 +466,7 @@ public class OperationTrackingStoreTests
|
||||
|
||||
// Cursor at the middle row's UpdatedAtUtc: inclusive lower bound, so
|
||||
// middle + newer come back, older is excluded.
|
||||
var result = await store.ReadChangedSinceAsync(t0.AddMinutes(10), batchSize: 100, CancellationToken.None);
|
||||
var result = await store.ReadChangedSinceAsync(t0.AddMinutes(10), batchSize: 100, ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(middle, result[0].TrackedOperationId);
|
||||
@@ -483,7 +483,7 @@ public class OperationTrackingStoreTests
|
||||
await store.RecordEnqueueAsync(TrackedOperationId.New(), nameof(AuditKind.ApiCallCached), "A", null, null, null);
|
||||
await store.RecordEnqueueAsync(TrackedOperationId.New(), nameof(AuditKind.ApiCallCached), "B", null, null, null);
|
||||
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100, CancellationToken.None);
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100, ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
@@ -504,7 +504,7 @@ public class OperationTrackingStoreTests
|
||||
SetUpdatedAt(dataSource, id, t0.AddMinutes(i));
|
||||
}
|
||||
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 3, CancellationToken.None);
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 3, ct: CancellationToken.None);
|
||||
|
||||
// Capped to 3 — and the cap takes the OLDEST 3 (asc order) so the
|
||||
// caller can advance the cursor monotonically across follow-up pulls.
|
||||
@@ -527,7 +527,7 @@ public class OperationTrackingStoreTests
|
||||
await store.RecordAttemptAsync(apiId, nameof(AuditStatus.Attempted), 2, "HTTP 503", 503);
|
||||
await store.RecordTerminalAsync(dbId, nameof(AuditStatus.Parked), "max retries", null);
|
||||
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100, CancellationToken.None);
|
||||
var result = await store.ReadChangedSinceAsync(DateTime.MinValue, batchSize: 100, ct: CancellationToken.None);
|
||||
var api = result.Single(r => r.TrackedOperationId == apiId);
|
||||
var db = result.Single(r => r.TrackedOperationId == dbId);
|
||||
|
||||
@@ -720,4 +720,92 @@ public class OperationTrackingStoreTests
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
}
|
||||
|
||||
// ── Task 15: composite (UpdatedAtUtc, TrackedOperationId) keyset ──
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <c>batchSize + 2</c> rows that all share ONE <c>UpdatedAtUtc</c> and
|
||||
/// pages through them with the composite keyset. Page 1 (no cursor) returns the
|
||||
/// first <c>batchSize</c>; page 2 resumes from <c>(sinceUtc, lastId)</c> and
|
||||
/// returns the REMAINING rows with no overlap — the stall the plain inclusive
|
||||
/// <c>>=</c> resume can never escape.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadChangedSince_CompositeKeyset_AdvancesPastSharedTimestamp()
|
||||
{
|
||||
const int batchSize = 3;
|
||||
var (store, dataSource) = CreateStore(nameof(ReadChangedSince_CompositeKeyset_AdvancesPastSharedTimestamp));
|
||||
await using var _store = store;
|
||||
|
||||
for (var i = 0; i < batchSize + 2; i++)
|
||||
{
|
||||
await store.RecordEnqueueAsync(TrackedOperationId.New(),
|
||||
kind: nameof(AuditKind.ApiCallCached), targetSummary: $"ERP.{i}",
|
||||
sourceInstanceId: "I", sourceScript: "S", sourceNode: "node-a");
|
||||
}
|
||||
|
||||
var boundary = ForceSharedUpdatedAt(dataSource);
|
||||
|
||||
// Page 1: no cursor → the first batchSize rows, ordered by TrackedOperationId.
|
||||
var page1 = await store.ReadChangedSinceAsync(boundary, batchSize);
|
||||
Assert.Equal(batchSize, page1.Count);
|
||||
|
||||
// Page 2: resume strictly after the last id of page 1.
|
||||
var afterId = page1[^1].TrackedOperationId.ToString();
|
||||
var page2 = await store.ReadChangedSinceAsync(boundary, batchSize, afterId);
|
||||
|
||||
// The remaining two rows, none seen on page 1, all strictly greater than the cursor.
|
||||
Assert.Equal(2, page2.Count);
|
||||
var page1Ids = page1.Select(r => r.TrackedOperationId).ToHashSet();
|
||||
Assert.All(page2, r => Assert.DoesNotContain(r.TrackedOperationId, page1Ids));
|
||||
Assert.All(page2, r => Assert.True(
|
||||
string.CompareOrdinal(r.TrackedOperationId.ToString(), afterId) > 0));
|
||||
|
||||
// Union covers every seeded row exactly once — no gap, no dup.
|
||||
Assert.Equal(batchSize + 2, page1Ids.Concat(page2.Select(r => r.TrackedOperationId)).Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the legacy contract: with NO cursor a resume at the
|
||||
/// shared timestamp re-reads the same page (the exact stall the keyset fixes) —
|
||||
/// an older central that never sends <c>after_id</c> is byte-for-byte unaffected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadChangedSince_WithoutKeyset_ReReadsSamePage_LegacyPreserved()
|
||||
{
|
||||
const int batchSize = 3;
|
||||
var (store, dataSource) = CreateStore(nameof(ReadChangedSince_WithoutKeyset_ReReadsSamePage_LegacyPreserved));
|
||||
await using var _store = store;
|
||||
|
||||
for (var i = 0; i < batchSize + 2; i++)
|
||||
{
|
||||
await store.RecordEnqueueAsync(TrackedOperationId.New(),
|
||||
kind: nameof(AuditKind.ApiCallCached), targetSummary: $"ERP.{i}",
|
||||
sourceInstanceId: "I", sourceScript: "S", sourceNode: "node-a");
|
||||
}
|
||||
|
||||
var boundary = ForceSharedUpdatedAt(dataSource);
|
||||
|
||||
var page1 = await store.ReadChangedSinceAsync(boundary, batchSize);
|
||||
// Legacy resume: same sinceUtc, no cursor → identical page (the stall).
|
||||
var pageAgain = await store.ReadChangedSinceAsync(boundary, batchSize);
|
||||
|
||||
Assert.Equal(
|
||||
page1.Select(r => r.TrackedOperationId).ToList(),
|
||||
pageAgain.Select(r => r.TrackedOperationId).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Forces every OperationTracking row to one exact UpdatedAtUtc instant; returns it.</summary>
|
||||
private DateTime ForceSharedUpdatedAt(string dataSource)
|
||||
{
|
||||
var boundary = DateTime.UtcNow;
|
||||
var boundaryText = DateTime.SpecifyKind(boundary, DateTimeKind.Utc)
|
||||
.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
|
||||
using var conn = OpenVerifierConnection(dataSource);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE OperationTracking SET UpdatedAtUtc = $ts";
|
||||
cmd.Parameters.AddWithValue("$ts", boundaryText);
|
||||
cmd.ExecuteNonQuery();
|
||||
return boundary;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user