fix(audit): race-safe channel cache + UTC-kind cursor handling in gRPC pull client (review)

This commit is contained in:
Joseph Doherty
2026-06-15 09:49:43 -04:00
parent 2adc5767da
commit d03c2af9a1
2 changed files with 91 additions and 10 deletions
@@ -114,9 +114,13 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
siteId, endpoint);
return Empty;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
catch (OperationCanceledException)
{
// Reconciliation tick was cancelled (host shutdown / scope dispose).
// Reconciliation tick was cancelled — either the caller's token
// (host shutdown / scope dispose) or an internal gRPC deadline /
// linked-CTS cancellation. Both are tolerable for a best-effort
// pull; collapse to empty rather than letting an internal
// cancellation land noisily in the catch-all below.
return Empty;
}
catch (Exception ex)
@@ -165,10 +169,13 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
StatusCode.DeadlineExceeded or
StatusCode.Cancelled;
// All ScadaBridge timestamps are UTC by invariant. A non-UTC cursor (the
// reconciliation cursor starts at DateTime.MinValue, Kind=Unspecified) is
// therefore treated AS UTC — never ToUniversalTime()-converted: on a host
// with a positive UTC offset MinValue.ToUniversalTime() underflows and
// Timestamp.FromDateTime throws, crashing the first pull for every site.
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
/// <summary>
/// Seam over the <c>PullAuditEvents</c> unary gRPC call against a resolved
@@ -195,10 +202,15 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
/// Production <see cref="GrpcPullAuditEventsClient.IPullAuditEventsInvoker"/>:
/// caches one <see cref="GrpcChannel"/> per endpoint (keepalive from
/// <see cref="CommunicationOptions"/>, mirroring <c>SiteStreamGrpcClient</c>)
/// and issues the unary <c>PullAuditEventsAsync</c> call. The cache flushes a
/// stale channel when an endpoint is re-keyed (NodeA→NodeB failover / address
/// edit), the same liveness guarantee <c>SiteStreamGrpcClientFactory</c> gives
/// the streaming client.
/// and issues the unary <c>PullAuditEventsAsync</c> call. The cache is keyed by
/// endpoint string, so a changed site address (NodeA→NodeB failover flip / an
/// edited gRPC address) is reached as soon as the resolver hands the new
/// endpoint to <see cref="InvokeAsync"/> — it creates a fresh channel for the
/// new address. Unlike <c>SiteStreamGrpcClientFactory</c> (keyed by siteId,
/// which actively evicts a re-keyed client), the channel for the previous
/// address is NOT actively evicted here; it lingers idle until
/// <see cref="Dispose"/>. Idle channels hold no streams, so this is a minor
/// cache footprint cost, not a correctness or liveness gap.
/// </summary>
public sealed class GrpcPullAuditEventsInvoker
: GrpcPullAuditEventsClient.IPullAuditEventsInvoker, IDisposable
@@ -228,12 +240,32 @@ public sealed class GrpcPullAuditEventsInvoker
public async Task<ProtoPullResponse> InvokeAsync(
string endpoint, ProtoPullRequest request, CancellationToken ct)
{
var channel = _channels.GetOrAdd(endpoint, CreateChannel);
var channel = GetOrCreateChannel(endpoint);
var client = new SiteStreamService.SiteStreamServiceClient(channel);
using var call = client.PullAuditEventsAsync(request, cancellationToken: ct);
return await call.ResponseAsync.ConfigureAwait(false);
}
// Race-safe channel cache. ConcurrentDictionary.GetOrAdd(key, valueFactory)
// does NOT serialize the factory, so two concurrent first dials of the same
// endpoint can both build a GrpcChannel (each holds an HTTP/2 connection
// pool) and the loser would leak. Create-then-GetOrAdd-then-dispose-if-lost
// mirrors SiteStreamGrpcClientFactory: only the channel actually installed
// survives; a channel that lost the race is disposed immediately.
private GrpcChannel GetOrCreateChannel(string endpoint)
{
if (!_channels.TryGetValue(endpoint, out var channel))
{
var created = CreateChannel(endpoint);
channel = _channels.GetOrAdd(endpoint, created);
if (!ReferenceEquals(channel, created))
{
created.Dispose();
}
}
return channel;
}
private GrpcChannel CreateChannel(string endpoint) =>
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
{