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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user