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
@@ -365,6 +365,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
public async Task<IReadOnlyList<SiteCallOperational>> ReadChangedSinceAsync(
DateTime sinceUtc,
int batchSize,
string? afterId = null,
CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
@@ -379,18 +380,28 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
await readConnection.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = readConnection.CreateCommand();
// Inclusive lower bound on UpdatedAtUtc (>=) so a caller resuming from
// the last returned timestamp does not skip a row sharing that instant;
// central ingest is insert-if-not-exists + upsert-on-newer, so the
// boundary row re-read is a no-op. ORDER BY ... ASC + LIMIT yields the
// OLDEST matching rows so the cursor advances monotonically.
cmd.CommandText = """
// Composite (UpdatedAtUtc, TrackedOperationId) keyset (Task 15). Ordering
// is ALWAYS deterministic (UpdatedAtUtc ASC, TrackedOperationId ASC) so the
// cursor is well-defined even when many rows share one UpdatedAtUtc.
// • afterId present → strict keyset: skip everything up to and including
// (sinceUtc, afterId). Without this a page fully inside one UpdatedAtUtc
// instant re-reads forever (the plain >= resume never advances past the
// tie). TrackedOperationId is the "D" GUID form on both sides, so the
// TEXT comparison is a stable total order.
// • afterId null/empty → legacy inclusive >= sinceUtc (an older central
// that does not send a cursor is byte-for-byte unaffected).
var useKeyset = !string.IsNullOrEmpty(afterId);
var predicate = useKeyset
? "UpdatedAtUtc > $since OR (UpdatedAtUtc = $since AND TrackedOperationId > $afterId)"
: "UpdatedAtUtc >= $since";
cmd.CommandText = $"""
SELECT TrackedOperationId, Kind, TargetSummary, Status,
RetryCount, LastError, HttpStatus,
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, SourceNode
FROM OperationTracking
WHERE UpdatedAtUtc >= $since
ORDER BY UpdatedAtUtc ASC
WHERE {predicate}
ORDER BY UpdatedAtUtc ASC, TrackedOperationId ASC
LIMIT $batchSize;
""";
// Force UTC kind before formatting so the cursor's "o" text matches the
@@ -403,6 +414,10 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
.ToString("o", CultureInfo.InvariantCulture);
cmd.Parameters.AddWithValue("$since", sinceText);
cmd.Parameters.AddWithValue("$batchSize", batchSize);
if (useKeyset)
{
cmd.Parameters.AddWithValue("$afterId", afterId!);
}
var rows = new List<SiteCallOperational>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);