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
@@ -137,6 +137,19 @@ public interface IOperationTrackingStore
/// the caller advance the cursor monotonically across follow-up pulls.
/// </para>
/// <para>
/// <b>Composite keyset (Task 15).</b> When <paramref name="afterId"/> is
/// supplied the read uses a <c>(UpdatedAtUtc, TrackedOperationId)</c> keyset:
/// it returns rows strictly after that cursor — <c>UpdatedAtUtc &gt; sinceUtc</c>,
/// or <c>UpdatedAtUtc = sinceUtc</c> with a <c>TrackedOperationId</c> greater
/// than <paramref name="afterId"/>. This un-pins a batch that would otherwise
/// stall: when more than <paramref name="batchSize"/> rows share one
/// <c>UpdatedAtUtc</c>, the plain inclusive <c>&gt;=</c> resume re-reads the
/// same page forever. When <paramref name="afterId"/> is <c>null</c>/empty the
/// legacy inclusive <c>&gt;=</c> contract is preserved exactly (an older
/// central that does not send the cursor is unaffected). Ordering is always
/// deterministic — <c>UpdatedAtUtc ASC, TrackedOperationId ASC</c>.
/// </para>
/// <para>
/// <see cref="SiteCallOperational.SourceSite"/> is left as the empty string:
/// the site id is not a tracking-store column, and the central client re-stamps
/// it from the <c>siteId</c> it dialed (the only authority that knows which
@@ -148,10 +161,17 @@ public interface IOperationTrackingStore
/// </remarks>
/// <param name="sinceUtc">Inclusive lower bound on <c>UpdatedAtUtc</c>; <see cref="DateTime.MinValue"/> reads from the start.</param>
/// <param name="batchSize">Maximum number of rows to return (oldest first).</param>
/// <param name="afterId">
/// Optional composite-keyset cursor — the <c>TrackedOperationId</c> of the last
/// row already consumed at <paramref name="sinceUtc"/>. When supplied the read
/// returns only rows strictly after <c>(sinceUtc, afterId)</c>; when
/// <c>null</c>/empty the legacy inclusive <c>&gt;= sinceUtc</c> behaviour applies.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The matching rows projected to <see cref="SiteCallOperational"/>, oldest-first, capped at <paramref name="batchSize"/>.</returns>
Task<IReadOnlyList<SiteCallOperational>> ReadChangedSinceAsync(
DateTime sinceUtc,
int batchSize,
string? afterId = null,
CancellationToken ct = default);
}
@@ -561,19 +561,24 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
: DateTime.MinValue;
// Composite-keyset cursor (Task 15): proto3 defaults an unset string to
// "", which the store treats as "no cursor" (legacy inclusive >= behaviour)
// — so an older central that never sets after_id is unaffected.
var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId;
IReadOnlyList<SiteCallOperational> operationals;
try
{
operationals = await store.ReadChangedSinceAsync(
since, request.BatchSize, context.CancellationToken);
since, request.BatchSize, afterId, context.CancellationToken);
}
catch (Exception ex)
{
// Best-effort, like PullAuditEvents: a read fault must never abort
// the reconciliation tick — central retries on its next cycle.
_logger.LogError(ex,
"ReadChangedSinceAsync failed for since={Since} batch={Batch}; returning empty response.",
since, request.BatchSize);
"ReadChangedSinceAsync failed for since={Since} batch={Batch} afterId={AfterId}; returning empty response.",
since, request.BatchSize, afterId);
return new PullSiteCallsResponse();
}
@@ -171,6 +171,14 @@ message PullAuditEventsResponse {
message PullSiteCallsRequest {
google.protobuf.Timestamp since_utc = 1;
int32 batch_size = 2;
// Composite-keyset cursor (Task 15): the TrackedOperationId ("D" GUID form) of
// the last row already consumed at since_utc. When set, the site returns only
// rows strictly after (since_utc, after_id) under a deterministic
// (UpdatedAtUtc, TrackedOperationId) order — un-pinning a batch that would
// otherwise stall when more than batch_size rows share one since_utc instant.
// Empty (the proto3 string default) preserves the legacy inclusive >= behaviour,
// so an older central that never sets it is unaffected. Additive-only.
string after_id = 3;
}
message PullSiteCallsResponse {
@@ -83,29 +83,30 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9z",
"aXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50",
"cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2",
"YWlsYWJsZRgCIAEoCCJZChRQdWxsU2l0ZUNhbGxzUmVxdWVzdBItCglzaW5j",
"YWlsYWJsZRgCIAEoCCJrChRQdWxsU2l0ZUNhbGxzUmVxdWVzdBItCglzaW5j",
"ZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJh",
"dGNoX3NpemUYAiABKAUiaQoVUHVsbFNpdGVDYWxsc1Jlc3BvbnNlEjgKDG9w",
"ZXJhdGlvbmFscxgBIAMoCzIiLnNpdGVzdHJlYW0uU2l0ZUNhbGxPcGVyYXRp",
"b25hbER0bxIWCg5tb3JlX2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcK",
"E1FVQUxJVFlfVU5TUEVDSUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFR",
"VUFMSVRZX1VOQ0VSVEFJThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1T",
"dGF0ZUVudW0SGwoXQUxBUk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFS",
"TV9TVEFURV9OT1JNQUwQARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoO",
"QWxhcm1MZXZlbEVudW0SFAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJN",
"X0xFVkVMX0xPVxABEhcKE0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFS",
"TV9MRVZFTF9ISUdIEAMSGQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQytwMK",
"EVNpdGVTdHJlYW1TZXJ2aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0",
"ZXN0cmVhbS5JbnN0YW5jZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNp",
"dGVTdHJlYW1FdmVudDABEkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0",
"cmVhbS5BdWRpdEV2ZW50QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQ",
"ChVJbmdlc3RDYWNoZWRUZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRl",
"bGVtZXRyeUJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1",
"ZGl0RXZlbnRzEiIuc2l0ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0",
"GiMuc2l0ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXNwb25zZRJUCg1QdWxs",
"U2l0ZUNhbGxzEiAuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVxdWVzdBoh",
"LnNpdGVzdHJlYW0uUHVsbFNpdGVDYWxsc1Jlc3BvbnNlQiuqAihaQi5NT00u",
"V1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlvbi5HcnBjYgZwcm90bzM="));
"dGNoX3NpemUYAiABKAUSEAoIYWZ0ZXJfaWQYAyABKAkiaQoVUHVsbFNpdGVD",
"YWxsc1Jlc3BvbnNlEjgKDG9wZXJhdGlvbmFscxgBIAMoCzIiLnNpdGVzdHJl",
"YW0uU2l0ZUNhbGxPcGVyYXRpb25hbER0bxIWCg5tb3JlX2F2YWlsYWJsZRgC",
"IAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQABIQCgxR",
"VUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8KC1FVQUxJ",
"VFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RBVEVfVU5T",
"UEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJBTEFSTV9T",
"VEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxBUk1fTEVW",
"RUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJNX0xFVkVM",
"X0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxBUk1fTEVW",
"RUxfSElHSF9ISUdIEAQytwMKEVNpdGVTdHJlYW1TZXJ2aWNlElUKEVN1YnNj",
"cmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVhbVJlcXVl",
"c3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUluZ2VzdEF1",
"ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2gaFS5zaXRl",
"c3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0cnkSIC5z",
"aXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0cmVhbS5J",
"bmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVhbS5QdWxs",
"QXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVkaXRFdmVu",
"dHNSZXNwb25zZRJUCg1QdWxsU2l0ZUNhbGxzEiAuc2l0ZXN0cmVhbS5QdWxs",
"U2l0ZUNhbGxzUmVxdWVzdBohLnNpdGVzdHJlYW0uUHVsbFNpdGVDYWxsc1Jl",
"c3BvbnNlQiuqAihaQi5NT00uV1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlv",
"bi5HcnBjYgZwcm90bzM="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -121,7 +122,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch.Parser, new[]{ "Packets" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest.Parser, new[]{ "SinceUtc", "BatchSize" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse.Parser, new[]{ "Events", "MoreAvailable" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest.Parser, new[]{ "SinceUtc", "BatchSize" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest.Parser, new[]{ "SinceUtc", "BatchSize", "AfterId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse.Parser, new[]{ "Operationals", "MoreAvailable" }, null, null, null, null)
}));
}
@@ -5200,6 +5201,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
public PullSiteCallsRequest(PullSiteCallsRequest other) : this() {
sinceUtc_ = other.sinceUtc_ != null ? other.sinceUtc_.Clone() : null;
batchSize_ = other.batchSize_;
afterId_ = other.afterId_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -5233,6 +5235,27 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
}
/// <summary>Field number for the "after_id" field.</summary>
public const int AfterIdFieldNumber = 3;
private string afterId_ = "";
/// <summary>
/// Composite-keyset cursor (Task 15): the TrackedOperationId ("D" GUID form) of
/// the last row already consumed at since_utc. When set, the site returns only
/// rows strictly after (since_utc, after_id) under a deterministic
/// (UpdatedAtUtc, TrackedOperationId) order — un-pinning a batch that would
/// otherwise stall when more than batch_size rows share one since_utc instant.
/// Empty (the proto3 string default) preserves the legacy inclusive >= behaviour,
/// so an older central that never sets it is unaffected. Additive-only.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string AfterId {
get { return afterId_; }
set {
afterId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -5250,6 +5273,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
if (!object.Equals(SinceUtc, other.SinceUtc)) return false;
if (BatchSize != other.BatchSize) return false;
if (AfterId != other.AfterId) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -5259,6 +5283,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
int hash = 1;
if (sinceUtc_ != null) hash ^= SinceUtc.GetHashCode();
if (BatchSize != 0) hash ^= BatchSize.GetHashCode();
if (AfterId.Length != 0) hash ^= AfterId.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -5285,6 +5310,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(16);
output.WriteInt32(BatchSize);
}
if (AfterId.Length != 0) {
output.WriteRawTag(26);
output.WriteString(AfterId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -5303,6 +5332,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(16);
output.WriteInt32(BatchSize);
}
if (AfterId.Length != 0) {
output.WriteRawTag(26);
output.WriteString(AfterId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -5319,6 +5352,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (BatchSize != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(BatchSize);
}
if (AfterId.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(AfterId);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -5340,6 +5376,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (other.BatchSize != 0) {
BatchSize = other.BatchSize;
}
if (other.AfterId.Length != 0) {
AfterId = other.AfterId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -5370,6 +5409,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
BatchSize = input.ReadInt32();
break;
}
case 26: {
AfterId = input.ReadString();
break;
}
}
}
#endif
@@ -5400,6 +5443,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
BatchSize = input.ReadInt32();
break;
}
case 26: {
AfterId = input.ReadString();
break;
}
}
}
}
@@ -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);