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