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();