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:
Joseph Doherty
2026-07-09 08:31:42 -04:00
parent 2c45c3238b
commit a608d7a79b
7 changed files with 285 additions and 43 deletions
@@ -72,7 +72,7 @@ public class SiteStreamPullSiteCallsTests : TestKit
{
var store = Substitute.For<IOperationTrackingStore>();
var rows = Enumerable.Range(0, 5).Select(_ => NewOperational()).ToList();
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<SiteCallOperational>)rows);
var server = CreateServer();
@@ -97,7 +97,7 @@ public class SiteStreamPullSiteCallsTests : TestKit
{
var store = Substitute.For<IOperationTrackingStore>();
var capturedSince = DateTime.MinValue;
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedSince = call.ArgAt<DateTime>(0);
@@ -121,6 +121,65 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Equal(since, capturedSince);
}
[Fact]
public async Task PullSiteCalls_AfterIdSet_PassesCursorThroughToStore()
{
// Task 15: a non-empty after_id is forwarded verbatim to the store as the
// composite-keyset cursor.
var store = Substitute.For<IOperationTrackingStore>();
string? capturedAfterId = "SENTINEL";
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedAfterId = call.ArgAt<string?>(2);
return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
});
var server = CreateServer();
server.SetOperationTrackingStore(store);
var cursor = Guid.NewGuid().ToString("D");
var request = new PullSiteCallsRequest
{
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
BatchSize = 50,
AfterId = cursor,
};
await server.PullSiteCalls(request, NewContext());
Assert.Equal(cursor, capturedAfterId);
}
[Fact]
public async Task PullSiteCalls_AfterIdUnset_PassesNullToStore()
{
// proto3 defaults an unset string to ""; the handler must normalise that to
// null so the store keeps its legacy inclusive >= behaviour (older central).
var store = Substitute.For<IOperationTrackingStore>();
string? capturedAfterId = "SENTINEL";
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
capturedAfterId = call.ArgAt<string?>(2);
return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
});
var server = CreateServer();
server.SetOperationTrackingStore(store);
var request = new PullSiteCallsRequest
{
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
BatchSize = 50,
// AfterId left unset → "" on the wire.
};
await server.PullSiteCalls(request, NewContext());
Assert.Null(capturedAfterId);
}
[Fact]
public async Task PullSiteCalls_SinceUtcUnset_PassesDateTimeMinValue()
{
@@ -130,7 +189,7 @@ public class SiteStreamPullSiteCallsTests : TestKit
// without a null-deref — this proves the very first cycle doesn't crash.
var store = Substitute.For<IOperationTrackingStore>();
var captured = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc); // sentinel
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
captured = call.ArgAt<DateTime>(0);
@@ -158,7 +217,7 @@ public class SiteStreamPullSiteCallsTests : TestKit
{
var store = Substitute.For<IOperationTrackingStore>();
var rows = Enumerable.Range(0, 3).Select(_ => NewOperational()).ToList();
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<SiteCallOperational>)rows);
var server = CreateServer();
@@ -200,7 +259,7 @@ public class SiteStreamPullSiteCallsTests : TestKit
{
// Best-effort: a read fault must never abort the reconciliation tick.
var store = Substitute.For<IOperationTrackingStore>();
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("SQLite disposed mid-call"));
var server = CreateServer();
@@ -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>&gt;=</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;
}
}