feat(sitecallaudit): PullSiteCalls reconciliation plumbing (store read + RPC + site handler + central client)

Site Call Audit (#22): build the documented periodic reconciliation PULL
self-heal path for the eventually-consistent central SiteCalls mirror, as a
dedicated PullSiteCalls gRPC RPC kept separate from the audit pull. This is the
pull PLUMBING only; the central reconciliation tick is a separate follow-up.

- IOperationTrackingStore.ReadChangedSinceAsync(sinceUtc, batchSize): inclusive
  UpdatedAtUtc cursor, oldest-first, batch-capped; SQLite impl projects tracking
  rows onto SiteCallOperational (Kind->Channel, TargetSummary->Target, SourceSite
  left empty - the store has no site-id column).
- sitestream.proto: rpc PullSiteCalls + PullSiteCallsRequest/Response, mirroring
  PullAuditEvents; regenerated checked-in SiteStreamGrpc/*.cs.
- SiteCallDtoMapper.ToDto(SiteCallOperational): inverse of FromDto for the handler.
- SiteStreamGrpcServer.PullSiteCalls handler + SetOperationTrackingStore seam;
  Host wires the seam alongside SetSiteAuditQueue (site roles only).
- Central IPullSiteCallsClient + GrpcPullSiteCallsClient (home: AuditLog/Central to
  reuse ISiteEnumerator; SiteCallAudit does not reference AuditLog). Re-stamps
  SourceSite from the dialed siteId; no-throw on tolerable transport faults;
  SpecifyKind (not ToUniversalTime) cursor handling. Central-only DI registration.

Tests: ReadChangedSinceAsync (4), PullSiteCalls handler (6), GrpcPullSiteCallsClient
(8). Full solution build 0 warnings/0 errors (TreatWarningsAsErrors).
This commit is contained in:
Joseph Doherty
2026-06-15 10:39:06 -04:00
parent c092e89fd1
commit 963e3427da
15 changed files with 1751 additions and 23 deletions
@@ -0,0 +1,287 @@
using System.Collections.Concurrent;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ProtoPullRequest = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest;
using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse;
using PullSiteCallsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration.PullSiteCallsResponse;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit #22) that the
/// central reconciliation tick (a separate follow-up component) uses to pull the
/// next batch of cached-call operational rows from a site over the
/// <c>PullSiteCalls</c> unary gRPC RPC served by <c>SiteStreamGrpcServer</c>.
/// A near-exact sibling of <see cref="GrpcPullAuditEventsClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Endpoint resolution.</b> The caller passes only a <c>siteId</c>; this
/// client resolves it to a gRPC authority via <see cref="ISiteEnumerator"/>
/// (<see cref="SiteEntry.GrpcEndpoint"/>) on every call so a NodeA→NodeB
/// failover flip or an edited site address takes effect on the next tick. A site
/// with no registered endpoint yields an empty response (no dial).
/// </para>
/// <para>
/// <b>SourceSite re-stamp.</b> The site leaves
/// <c>SiteCallOperationalDto.SourceSite</c> empty (the tracking store has no
/// site-id column). This client is the authority that knows which site it
/// dialed, so it re-stamps the mapped <see cref="SiteCall.SourceSite"/> from
/// <c>siteId</c> — the same "re-stamp from the forwarder's own id" pattern the
/// site push path uses.
/// </para>
/// <para>
/// <b>Fault tolerance.</b> Per the <see cref="IPullSiteCallsClient"/> contract,
/// tolerable transport faults (<see cref="StatusCode.Unavailable"/>,
/// <see cref="StatusCode.DeadlineExceeded"/>, <see cref="StatusCode.Cancelled"/>,
/// bare <see cref="HttpRequestException"/> / <c>SocketException</c>) are caught
/// and collapsed to an empty response so one offline site never sinks the rest
/// of the reconciliation tick. Any other fault (e.g. a malformed reply that
/// fails DTO mapping) is also swallowed to empty: reconciliation is best-effort.
/// </para>
/// <para>
/// <b>Testability.</b> The unary call is reached through the
/// <see cref="IPullSiteCallsInvoker"/> seam. Production binds
/// <see cref="GrpcPullSiteCallsInvoker"/> (one cached <see cref="GrpcChannel"/>
/// per endpoint, keepalive from <see cref="CommunicationOptions"/>); unit tests
/// inject a fake invoker so no real HTTP/2 endpoint is required.
/// </para>
/// </remarks>
public sealed class GrpcPullSiteCallsClient : IPullSiteCallsClient
{
private readonly ISiteEnumerator _sites;
private readonly IPullSiteCallsInvoker _invoker;
private readonly ILogger<GrpcPullSiteCallsClient> _logger;
/// <summary>
/// Creates the client over the given site enumerator and unary-call invoker.
/// </summary>
/// <param name="sites">Resolves a <c>siteId</c> to its gRPC endpoint.</param>
/// <param name="invoker">Seam that issues the <c>PullSiteCalls</c> unary RPC against a resolved endpoint.</param>
/// <param name="logger">Logger for transport-fault diagnostics.</param>
public GrpcPullSiteCallsClient(
ISiteEnumerator sites,
IPullSiteCallsInvoker invoker,
ILogger<GrpcPullSiteCallsClient> logger)
{
_sites = sites ?? throw new ArgumentNullException(nameof(sites));
_invoker = invoker ?? throw new ArgumentNullException(nameof(invoker));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<PullSiteCallsResponse> PullAsync(
string siteId,
DateTime sinceUtc,
int batchSize,
CancellationToken ct)
{
var endpoint = await ResolveEndpointAsync(siteId, ct).ConfigureAwait(false);
if (endpoint is null)
{
// No gRPC address registered for the site — a configuration decision
// (mirrors ISiteEnumerator's own contract), not a runtime error, so
// there is simply nothing to pull.
_logger.LogDebug(
"PullSiteCalls skipped: no gRPC endpoint registered for site {SiteId}.", siteId);
return Empty;
}
var request = new ProtoPullRequest
{
// ReadChangedSinceAsync treats DateTime.MinValue as "from the start";
// EnsureUtc keeps Timestamp.FromDateTime happy (it requires UTC kind).
SinceUtc = Timestamp.FromDateTime(EnsureUtc(sinceUtc)),
BatchSize = batchSize,
};
ProtoPullResponse reply;
try
{
reply = await _invoker.InvokeAsync(endpoint, request, ct).ConfigureAwait(false);
}
catch (RpcException ex) when (IsTolerable(ex.StatusCode))
{
_logger.LogDebug(ex,
"PullSiteCalls tolerable transport fault for site {SiteId} ({Endpoint}): {Status}. Returning empty batch.",
siteId, endpoint, ex.StatusCode);
return Empty;
}
catch (Exception ex) when (ex is HttpRequestException or System.Net.Sockets.SocketException)
{
_logger.LogDebug(ex,
"PullSiteCalls connection-layer fault for site {SiteId} ({Endpoint}). Returning empty batch.",
siteId, endpoint);
return Empty;
}
catch (OperationCanceledException)
{
// Reconciliation tick cancelled — caller token (host shutdown) or an
// internal gRPC deadline / linked-CTS cancellation. Both tolerable for
// a best-effort pull; collapse to empty rather than landing noisily in
// the catch-all below.
return Empty;
}
catch (Exception ex)
{
// Any other fault. Reconciliation is best-effort; swallow to empty
// rather than throw — the (future) actor's per-site guard would only
// re-catch it.
_logger.LogWarning(ex,
"PullSiteCalls unexpected fault for site {SiteId} ({Endpoint}). Returning empty batch.",
siteId, endpoint);
return Empty;
}
// Map proto DTOs to central SiteCall entities, re-stamp SourceSite from
// the dialed siteId (the site leaves it empty), and order oldest-first by
// UpdatedAtUtc (the wire is already ordered by the site read, but the
// contract is explicit, so sort defensively).
var siteCalls = reply.Operationals
.Select(SiteCallDtoMapper.FromDto)
.Select(sc => sc with { SourceSite = siteId })
.OrderBy(sc => sc.UpdatedAtUtc)
.ToList();
return new PullSiteCallsResponse(siteCalls, reply.MoreAvailable);
}
private async Task<string?> ResolveEndpointAsync(string siteId, CancellationToken ct)
{
var sites = await _sites.EnumerateAsync(ct).ConfigureAwait(false);
foreach (var site in sites)
{
if (string.Equals(site.SiteId, siteId, StringComparison.Ordinal) &&
!string.IsNullOrWhiteSpace(site.GrpcEndpoint))
{
return site.GrpcEndpoint;
}
}
return null;
}
private static readonly PullSiteCallsResponse Empty =
new(Array.Empty<SiteCall>(), MoreAvailable: false);
private static bool IsTolerable(StatusCode code) => code is
StatusCode.Unavailable or
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
// 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, DateTimeKind.Utc);
/// <summary>
/// Seam over the <c>PullSiteCalls</c> unary gRPC call against a resolved site
/// endpoint. Extracted so <see cref="GrpcPullSiteCallsClient"/> can be
/// unit-tested without a real <see cref="GrpcChannel"/>. Production binds
/// <see cref="GrpcPullSiteCallsInvoker"/>.
/// </summary>
public interface IPullSiteCallsInvoker
{
/// <summary>
/// Issues the <c>PullSiteCalls</c> unary RPC against <paramref name="endpoint"/>.
/// May throw <see cref="RpcException"/> / <see cref="HttpRequestException"/>
/// on transport faults — the caller classifies and swallows tolerable ones.
/// </summary>
/// <param name="endpoint">The site gRPC authority (e.g. <c>http://site-a:8083</c>).</param>
/// <param name="request">The wire-format pull request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The wire-format pull response.</returns>
Task<ProtoPullResponse> InvokeAsync(string endpoint, ProtoPullRequest request, CancellationToken ct);
}
}
/// <summary>
/// Production <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/>: caches
/// one <see cref="GrpcChannel"/> per endpoint (keepalive from
/// <see cref="CommunicationOptions"/>, mirroring <c>SiteStreamGrpcClient</c>) and
/// issues the unary <c>PullSiteCallsAsync</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"/>. The channel for a previous address lingers idle
/// until <see cref="Dispose"/> (idle channels hold no streams — a minor cache
/// footprint cost, not a correctness or liveness gap). Sibling of
/// <see cref="GrpcPullAuditEventsInvoker"/>.
/// </summary>
public sealed class GrpcPullSiteCallsInvoker
: GrpcPullSiteCallsClient.IPullSiteCallsInvoker, IDisposable
{
private readonly ConcurrentDictionary<string, GrpcChannel> _channels = new(StringComparer.Ordinal);
private readonly CommunicationOptions _options;
/// <summary>Creates the invoker using default <see cref="CommunicationOptions"/>.</summary>
public GrpcPullSiteCallsInvoker()
: this(new CommunicationOptions())
{
}
/// <summary>
/// Creates the invoker, applying the configured gRPC keepalive settings to
/// every channel it opens.
/// </summary>
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
public GrpcPullSiteCallsInvoker(CommunicationOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public async Task<ProtoPullResponse> InvokeAsync(
string endpoint, ProtoPullRequest request, CancellationToken ct)
{
var channel = GetOrCreateChannel(endpoint);
var client = new SiteStreamService.SiteStreamServiceClient(channel);
using var call = client.PullSiteCallsAsync(request, cancellationToken: ct);
return await call.ResponseAsync.ConfigureAwait(false);
}
// Race-safe channel cache (create-then-GetOrAdd-then-dispose-if-lost): two
// concurrent first dials of the same endpoint can both build a GrpcChannel;
// only the channel actually installed survives, the loser is disposed.
// Mirrors SiteStreamGrpcClientFactory / GrpcPullAuditEventsInvoker.
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
{
HttpHandler = new SocketsHttpHandler
{
KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
},
});
/// <summary>Disposes all cached channels.</summary>
public void Dispose()
{
foreach (var channel in _channels.Values)
{
channel.Dispose();
}
_channels.Clear();
}
}
@@ -0,0 +1,57 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Mockable abstraction over the central-side <c>PullSiteCalls</c> gRPC client
/// surface used by the Site Call Audit (#22) reconciliation tick to fetch the
/// next batch of cached-call operational rows from a specific site — the
/// documented periodic self-heal pull that backfills the eventually-consistent
/// central <c>SiteCalls</c> mirror when best-effort push telemetry is lost.
/// Extracted so the (separate, follow-up) reconciliation actor can be
/// unit-tested against an in-memory stub without standing up a real
/// <c>GrpcChannel</c> per site.
/// </summary>
/// <remarks>
/// <para>
/// The home is <c>ZB.MOM.WW.ScadaBridge.AuditLog.Central</c> rather than the
/// <c>ZB.MOM.WW.ScadaBridge.SiteCallAudit</c> project so it can reuse the
/// <see cref="ISiteEnumerator"/> / <see cref="SiteEntry"/> endpoint-resolution
/// abstraction that already lives here (and that the sibling
/// <see cref="IPullAuditEventsClient"/> uses) — SiteCallAudit does not reference
/// AuditLog, so hosting the client there would mean duplicating the enumerator.
/// This mirrors the decision to keep <see cref="SiteCallDtoMapper"/> in
/// <c>ZB.MOM.WW.ScadaBridge.Communication</c>.
/// </para>
/// <para>
/// Implementations MUST NOT throw on transport faults the reconciliation tick
/// can tolerate (connection refused, deadline exceeded, cancellation) — one
/// offline site must never sink the rest of the tick. The
/// <see cref="PullSiteCallsResponse.SiteCalls"/> are returned oldest-first by
/// <c>UpdatedAtUtc</c> with the <c>SourceSite</c> re-stamped from the dialed
/// site id (the site leaves it empty, being unaware of its own id), and a
/// <c>MoreAvailable</c> flag the caller uses to decide whether to fire another
/// pull immediately.
/// </para>
/// </remarks>
public interface IPullSiteCallsClient
{
/// <summary>
/// Issues a <c>PullSiteCalls</c> RPC against the site whose gRPC endpoint is
/// registered against <paramref name="siteId"/>. Returns the next batch of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/> rows
/// ordered oldest-first (with <c>SourceSite</c> re-stamped from
/// <paramref name="siteId"/>) AND a <c>MoreAvailable</c> flag the caller uses
/// to decide whether to fire another pull immediately.
/// </summary>
/// <param name="siteId">The identifier of the site to pull cached-call operational rows from.</param>
/// <param name="sinceUtc">Only rows with an <c>UpdatedAtUtc</c> at or after this cursor time are returned.</param>
/// <param name="batchSize">Maximum number of rows to return per call.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the next reconciliation batch with a <c>MoreAvailable</c> flag.</returns>
Task<PullSiteCallsResponse> PullAsync(
string siteId,
DateTime sinceUtc,
int batchSize,
CancellationToken ct);
}
@@ -473,6 +473,31 @@ public static class ServiceCollectionExtensions
sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(),
sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>()));
// Site Call Audit (#22) reconciliation pull client — central-only, the
// sibling of the audit pull client above. Lives here (not in the
// SiteCallAudit project) so it can reuse the central-only
// ISiteEnumerator registered just above; SiteCallAudit does not
// reference AuditLog. The invoker owns the per-endpoint GrpcChannel
// cache, so it must be a singleton (a fresh invoker per resolution
// would leak channels). CommunicationOptions flow through when bound by
// the central Host, else defaults — mirrors the audit invoker.
services.TryAddSingleton<GrpcPullSiteCallsInvoker>(sp =>
{
var options = sp
.GetService<Microsoft.Extensions.Options.IOptions<
ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
return options is null
? new GrpcPullSiteCallsInvoker()
: new GrpcPullSiteCallsInvoker(options.Value);
});
services.TryAddSingleton<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(
sp => sp.GetRequiredService<GrpcPullSiteCallsInvoker>());
services.TryAddSingleton<IPullSiteCallsClient>(sp => new GrpcPullSiteCallsClient(
sp.GetRequiredService<ISiteEnumerator>(),
sp.GetRequiredService<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(),
sp.GetRequiredService<ILogger<GrpcPullSiteCallsClient>>()));
return services;
}
}
@@ -118,4 +118,40 @@ public interface IOperationTrackingStore
Task PurgeTerminalAsync(
DateTime olderThanUtc,
CancellationToken ct = default);
/// <summary>
/// Reconciliation read (Site Call Audit #22): return tracking rows whose
/// <c>UpdatedAtUtc</c> is at or after <paramref name="sinceUtc"/> as
/// <see cref="SiteCallOperational"/> projections, ordered by
/// <c>UpdatedAtUtc</c> ascending and capped at <paramref name="batchSize"/>.
/// This is the site-side feed for central's <c>PullSiteCalls</c> RPC — the
/// documented periodic self-heal pull that backfills the eventually-consistent
/// central <c>SiteCalls</c> mirror when best-effort push telemetry is lost.
/// </summary>
/// <remarks>
/// <para>
/// The lower bound is inclusive so a caller can resume from the last
/// returned <c>UpdatedAtUtc</c> without skipping a row that shares that
/// instant; central ingest is insert-if-not-exists then upsert-on-newer, so
/// re-reading the boundary row is a harmless no-op. The oldest-first cap lets
/// the caller advance the cursor monotonically across follow-up pulls.
/// </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
/// site the rows came from). <see cref="SiteCallOperational.Channel"/> is
/// projected from the row's <c>Kind</c> (<c>DbWriteCached → DbOutbound</c>,
/// otherwise <c>ApiOutbound</c>) and <see cref="SiteCallOperational.Target"/>
/// from <c>TargetSummary</c>.
/// </para>
/// </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="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,
CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
/// <summary>
/// Site Call Audit (#22) periodic reconciliation pull response: the next batch of
/// site cached-call operational rows (the eventually-consistent <c>SiteCalls</c>
/// mirror's self-heal feed) plus a <paramref name="MoreAvailable"/> flag signalling
/// the caller to advance the watermark and pull again. Mirrors
/// <see cref="PullAuditEventsResponse"/>; carries the central <see cref="SiteCall"/>
/// entity the ingest path upserts. See Component-SiteCallAudit.md.
/// </summary>
/// <param name="SiteCalls">The next batch of operational rows, ordered oldest-first by <see cref="SiteCall.UpdatedAtUtc"/>.</param>
/// <param name="MoreAvailable">True when the site saturated the requested batch size — the caller should advance the cursor and pull again.</param>
public sealed record PullSiteCallsResponse(
IReadOnlyList<SiteCall> SiteCalls,
bool MoreAvailable);
@@ -1,5 +1,6 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -20,10 +21,15 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
/// </para>
/// <para>
/// Only the DTO→entity direction is provided: nothing in the system maps a
/// <see cref="SiteCall"/> back onto the wire (sites emit the operational state
/// from <c>SiteCallOperational</c>, never from the central <see cref="SiteCall"/>
/// entity), so an entity→DTO method would be dead code.
/// Two directions are provided. <see cref="FromDto"/> rehydrates the central
/// <see cref="SiteCall"/> entity central writes into the <c>SiteCalls</c> table.
/// <see cref="ToDto"/> projects a site-local <see cref="SiteCallOperational"/>
/// onto the wire — used by the Site Call Audit (#22) <c>PullSiteCalls</c>
/// reconciliation handler (the central→site self-heal pull). The
/// <see cref="SiteCall"/> entity itself is never mapped back onto the wire:
/// sites emit operational state from <see cref="SiteCallOperational"/>, never
/// from the central <see cref="SiteCall"/>, so a <c>SiteCall</c>→DTO method
/// would be dead code.
/// </para>
/// <para>
/// String nullability convention: proto3 scalar strings cannot be absent, so the
@@ -70,4 +76,54 @@ public static class SiteCallDtoMapper
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
};
}
/// <summary>
/// Projects a site-local <see cref="SiteCallOperational"/> onto its
/// wire-format DTO for the Site Call Audit (#22) <c>PullSiteCalls</c>
/// reconciliation RPC. The inverse of <see cref="FromDto"/>; null
/// <see cref="SiteCallOperational.LastError"/> / <see cref="SiteCallOperational.SourceNode"/>
/// collapse to empty strings (proto3 scalar strings cannot be absent), while
/// the nullable <c>HttpStatus</c> and <c>TerminalAtUtc</c> stay unset on the
/// wire so true-null semantics survive the round-trip back through
/// <see cref="FromDto"/>.
/// </summary>
/// <param name="operational">The site-local operational state to project to wire format.</param>
/// <returns>A populated <see cref="SiteCallOperationalDto"/> ready for transmission.</returns>
public static SiteCallOperationalDto ToDto(SiteCallOperational operational)
{
ArgumentNullException.ThrowIfNull(operational);
var dto = new SiteCallOperationalDto
{
TrackedOperationId = operational.TrackedOperationId.ToString(),
Channel = operational.Channel,
Target = operational.Target,
SourceSite = operational.SourceSite,
SourceNode = operational.SourceNode ?? string.Empty,
Status = operational.Status,
RetryCount = operational.RetryCount,
LastError = operational.LastError ?? string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(operational.CreatedAtUtc)),
UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(operational.UpdatedAtUtc)),
};
if (operational.HttpStatus.HasValue)
{
dto.HttpStatus = operational.HttpStatus.Value;
}
if (operational.TerminalAtUtc.HasValue)
{
dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(operational.TerminalAtUtc.Value));
}
return dto;
}
// All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime
// requires UTC kind. Specify (never convert) so a row read back from SQLite
// with Kind=Utc passes through and a defensively-unspecified value is
// treated as the UTC it already is. Mirrors AuditEventDtoMapper.EnsureUtc.
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
@@ -5,7 +5,9 @@ using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using GrpcStatus = Grpc.Core.Status;
@@ -48,6 +50,14 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// the missing queue as "nothing to ship" and returns an empty response so
// central retries on its next reconciliation cycle.
private ISiteAuditQueue? _siteAuditQueue;
// Site Call Audit (#22): site-local operation-tracking store handed in by
// AkkaHostedService on site roles so the central reconciliation puller's
// PullSiteCalls RPC can read tracking rows changed since a cursor. Null
// when not wired (central-only host or test composing the server in
// isolation) — the handler treats the missing store as "nothing to ship"
// and returns an empty response so central retries on its next cycle.
// Mirrors _siteAuditQueue.
private IOperationTrackingStore? _operationTrackingStore;
/// <summary>
/// Test-only constructor — kept <c>internal</c> so the DI container sees a
@@ -137,6 +147,21 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
_siteAuditQueue = queue;
}
/// <summary>
/// Hands the site-local <see cref="IOperationTrackingStore"/> (the same
/// <c>OperationTrackingStore</c> singleton that backs
/// <c>Tracking.Status(id)</c> on the script thread) to the gRPC server so
/// the Site Call Audit (#22) <see cref="PullSiteCalls"/> RPC can serve
/// central's reconciliation pulls. Mirrors <see cref="SetSiteAuditQueue"/>:
/// wired post-construction because the store and the gRPC server are both
/// DI singletons brought up in independent orders on site startup.
/// </summary>
/// <param name="store">The site operation-tracking store for serving reconciliation pulls.</param>
public void SetOperationTrackingStore(IOperationTrackingStore store)
{
_operationTrackingStore = store;
}
/// <summary>
/// Host-017 / REQ-HOST-7: signals the gRPC server to begin its part of the
/// site shutdown sequence — refuse new <see cref="SubscribeInstance"/>
@@ -488,6 +513,69 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
return response;
}
/// <inheritdoc />
public override async Task<PullSiteCallsResponse> PullSiteCalls(
PullSiteCallsRequest request,
ServerCallContext context)
{
var store = _operationTrackingStore;
if (store is null)
{
_logger.LogWarning(
"PullSiteCalls invoked before SetOperationTrackingStore was called; returning empty response.");
return new PullSiteCallsResponse();
}
if (request.BatchSize <= 0)
{
// Mirrors PullAuditEvents: reject malformed requests cleanly with
// InvalidArgument so the caller doesn't see a generic RpcException
// from the underlying SQLite parameter validation.
throw new RpcException(new GrpcStatus(
StatusCode.InvalidArgument, "batch_size must be > 0"));
}
// since_utc defaults to DateTime.MinValue when the wrapper is absent —
// i.e. "pull from the beginning of recorded history", the intended
// behaviour for the very first reconciliation cycle. ToUniversalTime
// is safe here (the wire value is always a real UTC Timestamp, never the
// unspecified-MinValue the central client guards against on its side).
var since = request.SinceUtc?.ToDateTime().ToUniversalTime() ?? DateTime.MinValue;
IReadOnlyList<SiteCallOperational> operationals;
try
{
operationals = await store.ReadChangedSinceAsync(
since, request.BatchSize, 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);
return new PullSiteCallsResponse();
}
var response = new PullSiteCallsResponse
{
// batch_size saturated → tell central to issue a follow-up pull with
// an advanced cursor. The site doesn't compute the cursor — central
// walks it forward from the last returned UpdatedAtUtc. Unlike
// PullAuditEvents there is no MarkReconciled step: the tracking store
// is the operational source of truth and the central SiteCalls mirror
// is upsert-on-newer, so re-reading rows is a harmless no-op.
MoreAvailable = operationals.Count >= request.BatchSize,
};
foreach (var op in operationals)
{
response.Operationals.Add(SiteCallDtoMapper.ToDto(op));
}
return response;
}
/// <summary>
/// Tracks a single active stream so cleanup only removes its own entry.
/// </summary>
@@ -10,6 +10,7 @@ service SiteStreamService {
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
rpc PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse);
rpc PullSiteCalls(PullSiteCallsRequest) returns (PullSiteCallsResponse);
}
message InstanceStreamRequest {
@@ -157,3 +158,20 @@ message PullAuditEventsResponse {
repeated AuditEventDto events = 1;
bool more_available = 2;
}
// Site Call Audit (#22) reconciliation pull: central→site request for any
// site-local operation-tracking rows whose UpdatedAtUtc >= since_utc — the
// self-heal feed that backfills the eventually-consistent central SiteCalls
// mirror when best-effort push telemetry is lost. Mirrors PullAuditEvents
// but is a SEPARATE RPC (the tracking store is the operational source of
// truth, distinct from the site audit queue). more_available signals
// batch_size was saturated so the caller advances since_utc and pulls again.
message PullSiteCallsRequest {
google.protobuf.Timestamp since_utc = 1;
int32 batch_size = 2;
}
message PullSiteCallsResponse {
repeated SiteCallOperationalDto operationals = 1;
bool more_available = 2;
}
@@ -81,23 +81,30 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"dWVzdBItCglzaW5jZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
"ZXN0YW1wEhIKCmJhdGNoX3NpemUYAiABKAUiXAoXUHVsbEF1ZGl0RXZlbnRz",
"UmVzcG9uc2USKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2",
"ZW50RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIKlwKB1F1YWxpdHkSFwoT",
"UVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlfR09PRBABEhUKEVFV",
"QUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQAypdCg5BbGFybVN0",
"YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklFRBAAEhYKEkFMQVJN",
"X1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FDVElWRRACKoUBCg5B",
"bGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05FEAASEwoPQUxBUk1f",
"TEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xPVxACEhQKEEFMQVJN",
"X0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdIX0hJR0gQBDLhAgoR",
"U2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vic2NyaWJlSW5zdGFuY2USIS5zaXRl",
"c3RyZWFtLkluc3RhbmNlU3RyZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0",
"ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVkaXRFdmVudHMSGy5zaXRlc3Ry",
"ZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrElAK",
"FUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNpdGVzdHJlYW0uQ2FjaGVkVGVs",
"ZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJaCg9QdWxsQXVk",
"aXRFdmVudHMSIi5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50c1JlcXVlc3Qa",
"Iy5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlQiuqAihaQi5N",
"T00uV1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlvbi5HcnBjYgZwcm90bzM="));
"ZW50RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIIlkKFFB1bGxTaXRlQ2Fs",
"bHNSZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1",
"Zi5UaW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJpChVQdWxsU2l0ZUNh",
"bGxzUmVzcG9uc2USOAoMb3BlcmF0aW9uYWxzGAEgAygLMiIuc2l0ZXN0cmVh",
"bS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIg",
"ASgIKlwKB1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFV",
"QUxJVFlfR09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElU",
"WV9CQUQQAypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQ",
"RUNJRklFRBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NU",
"QVRFX0FDVElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZF",
"TF9OT05FEAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxf",
"TE9XX0xPVxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZF",
"TF9ISUdIX0hJR0gQBDK3AwoRU2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vic2Ny",
"aWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3RhbmNlU3RyZWFtUmVxdWVz",
"dBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVk",
"aXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVz",
"dHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNp",
"dGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLklu",
"Z2VzdEFjaxJaCg9QdWxsQXVkaXRFdmVudHMSIi5zaXRlc3RyZWFtLlB1bGxB",
"dWRpdEV2ZW50c1JlcXVlc3QaIy5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50",
"c1Jlc3BvbnNlElQKDVB1bGxTaXRlQ2FsbHMSIC5zaXRlc3RyZWFtLlB1bGxT",
"aXRlQ2FsbHNSZXF1ZXN0GiEuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVz",
"cG9uc2VCK6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5Db21tdW5pY2F0aW9u",
"LkdycGNiBnByb3RvMw=="));
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[] {
@@ -112,7 +119,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryPacket), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryPacket.Parser, new[]{ "AuditEvent", "Operational" }, null, null, null, null),
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.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.PullSiteCallsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse.Parser, new[]{ "Operationals", "MoreAvailable" }, null, null, null, null)
}));
}
#endregion
@@ -5064,6 +5073,483 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
/// <summary>
/// Site Call Audit (#22) reconciliation pull: central→site request for any
/// site-local operation-tracking rows whose UpdatedAtUtc >= since_utc — the
/// self-heal feed that backfills the eventually-consistent central SiteCalls
/// mirror when best-effort push telemetry is lost. Mirrors PullAuditEvents
/// but is a SEPARATE RPC (the tracking store is the operational source of
/// truth, distinct from the site audit queue). more_available signals
/// batch_size was saturated so the caller advances since_utc and pulls again.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class PullSiteCallsRequest : pb::IMessage<PullSiteCallsRequest>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
, pb::IBufferMessage
#endif
{
private static readonly pb::MessageParser<PullSiteCallsRequest> _parser = new pb::MessageParser<PullSiteCallsRequest>(() => new PullSiteCallsRequest());
private pb::UnknownFieldSet _unknownFields;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pb::MessageParser<PullSiteCallsRequest> Parser { get { return _parser; } }
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pbr::MessageDescriptor Descriptor {
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[12]; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
pbr::MessageDescriptor pb::IMessage.Descriptor {
get { return Descriptor; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsRequest() {
OnConstruction();
}
partial void OnConstruction();
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsRequest(PullSiteCallsRequest other) : this() {
sinceUtc_ = other.sinceUtc_ != null ? other.sinceUtc_.Clone() : null;
batchSize_ = other.batchSize_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsRequest Clone() {
return new PullSiteCallsRequest(this);
}
/// <summary>Field number for the "since_utc" field.</summary>
public const int SinceUtcFieldNumber = 1;
private global::Google.Protobuf.WellKnownTypes.Timestamp sinceUtc_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public global::Google.Protobuf.WellKnownTypes.Timestamp SinceUtc {
get { return sinceUtc_; }
set {
sinceUtc_ = value;
}
}
/// <summary>Field number for the "batch_size" field.</summary>
public const int BatchSizeFieldNumber = 2;
private int batchSize_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int BatchSize {
get { return batchSize_; }
set {
batchSize_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
return Equals(other as PullSiteCallsRequest);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Equals(PullSiteCallsRequest other) {
if (ReferenceEquals(other, null)) {
return false;
}
if (ReferenceEquals(other, this)) {
return true;
}
if (!object.Equals(SinceUtc, other.SinceUtc)) return false;
if (BatchSize != other.BatchSize) return false;
return Equals(_unknownFields, other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override int GetHashCode() {
int hash = 1;
if (sinceUtc_ != null) hash ^= SinceUtc.GetHashCode();
if (BatchSize != 0) hash ^= BatchSize.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
return hash;
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override string ToString() {
return pb::JsonFormatter.ToDiagnosticString(this);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void WriteTo(pb::CodedOutputStream output) {
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
output.WriteRawMessage(this);
#else
if (sinceUtc_ != null) {
output.WriteRawTag(10);
output.WriteMessage(SinceUtc);
}
if (BatchSize != 0) {
output.WriteRawTag(16);
output.WriteInt32(BatchSize);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
#endif
}
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
if (sinceUtc_ != null) {
output.WriteRawTag(10);
output.WriteMessage(SinceUtc);
}
if (BatchSize != 0) {
output.WriteRawTag(16);
output.WriteInt32(BatchSize);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
}
#endif
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int CalculateSize() {
int size = 0;
if (sinceUtc_ != null) {
size += 1 + pb::CodedOutputStream.ComputeMessageSize(SinceUtc);
}
if (BatchSize != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(BatchSize);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
return size;
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void MergeFrom(PullSiteCallsRequest other) {
if (other == null) {
return;
}
if (other.sinceUtc_ != null) {
if (sinceUtc_ == null) {
SinceUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
SinceUtc.MergeFrom(other.SinceUtc);
}
if (other.BatchSize != 0) {
BatchSize = other.BatchSize;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void MergeFrom(pb::CodedInputStream input) {
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
input.ReadRawMessage(this);
#else
uint tag;
while ((tag = input.ReadTag()) != 0) {
if ((tag & 7) == 4) {
// Abort on any end group tag.
return;
}
switch(tag) {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
break;
case 10: {
if (sinceUtc_ == null) {
SinceUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
input.ReadMessage(SinceUtc);
break;
}
case 16: {
BatchSize = input.ReadInt32();
break;
}
}
}
#endif
}
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
uint tag;
while ((tag = input.ReadTag()) != 0) {
if ((tag & 7) == 4) {
// Abort on any end group tag.
return;
}
switch(tag) {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
break;
case 10: {
if (sinceUtc_ == null) {
SinceUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
input.ReadMessage(SinceUtc);
break;
}
case 16: {
BatchSize = input.ReadInt32();
break;
}
}
}
}
#endif
}
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class PullSiteCallsResponse : pb::IMessage<PullSiteCallsResponse>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
, pb::IBufferMessage
#endif
{
private static readonly pb::MessageParser<PullSiteCallsResponse> _parser = new pb::MessageParser<PullSiteCallsResponse>(() => new PullSiteCallsResponse());
private pb::UnknownFieldSet _unknownFields;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pb::MessageParser<PullSiteCallsResponse> Parser { get { return _parser; } }
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pbr::MessageDescriptor Descriptor {
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[13]; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
pbr::MessageDescriptor pb::IMessage.Descriptor {
get { return Descriptor; }
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsResponse() {
OnConstruction();
}
partial void OnConstruction();
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsResponse(PullSiteCallsResponse other) : this() {
operationals_ = other.operationals_.Clone();
moreAvailable_ = other.moreAvailable_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public PullSiteCallsResponse Clone() {
return new PullSiteCallsResponse(this);
}
/// <summary>Field number for the "operationals" field.</summary>
public const int OperationalsFieldNumber = 1;
private static readonly pb::FieldCodec<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCallOperationalDto> _repeated_operationals_codec
= pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCallOperationalDto.Parser);
private readonly pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCallOperationalDto> operationals_ = new pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCallOperationalDto>();
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCallOperationalDto> Operationals {
get { return operationals_; }
}
/// <summary>Field number for the "more_available" field.</summary>
public const int MoreAvailableFieldNumber = 2;
private bool moreAvailable_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool MoreAvailable {
get { return moreAvailable_; }
set {
moreAvailable_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
return Equals(other as PullSiteCallsResponse);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Equals(PullSiteCallsResponse other) {
if (ReferenceEquals(other, null)) {
return false;
}
if (ReferenceEquals(other, this)) {
return true;
}
if(!operationals_.Equals(other.operationals_)) return false;
if (MoreAvailable != other.MoreAvailable) return false;
return Equals(_unknownFields, other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override int GetHashCode() {
int hash = 1;
hash ^= operationals_.GetHashCode();
if (MoreAvailable != false) hash ^= MoreAvailable.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
return hash;
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override string ToString() {
return pb::JsonFormatter.ToDiagnosticString(this);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void WriteTo(pb::CodedOutputStream output) {
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
output.WriteRawMessage(this);
#else
operationals_.WriteTo(output, _repeated_operationals_codec);
if (MoreAvailable != false) {
output.WriteRawTag(16);
output.WriteBool(MoreAvailable);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
#endif
}
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
operationals_.WriteTo(ref output, _repeated_operationals_codec);
if (MoreAvailable != false) {
output.WriteRawTag(16);
output.WriteBool(MoreAvailable);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
}
#endif
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int CalculateSize() {
int size = 0;
size += operationals_.CalculateSize(_repeated_operationals_codec);
if (MoreAvailable != false) {
size += 1 + 1;
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
return size;
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void MergeFrom(PullSiteCallsResponse other) {
if (other == null) {
return;
}
operationals_.Add(other.operationals_);
if (other.MoreAvailable != false) {
MoreAvailable = other.MoreAvailable;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public void MergeFrom(pb::CodedInputStream input) {
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
input.ReadRawMessage(this);
#else
uint tag;
while ((tag = input.ReadTag()) != 0) {
if ((tag & 7) == 4) {
// Abort on any end group tag.
return;
}
switch(tag) {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
break;
case 10: {
operationals_.AddEntriesFrom(input, _repeated_operationals_codec);
break;
}
case 16: {
MoreAvailable = input.ReadBool();
break;
}
}
}
#endif
}
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
uint tag;
while ((tag = input.ReadTag()) != 0) {
if ((tag & 7) == 4) {
// Abort on any end group tag.
return;
}
switch(tag) {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
break;
case 10: {
operationals_.AddEntriesFrom(ref input, _repeated_operationals_codec);
break;
}
case 16: {
MoreAvailable = input.ReadBool();
break;
}
}
}
}
#endif
}
#endregion
}
@@ -59,6 +59,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest> __Marshaller_sitestream_PullAuditEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse> __Marshaller_sitestream_PullAuditEventsResponse = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest> __Marshaller_sitestream_PullSiteCallsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse> __Marshaller_sitestream_PullSiteCallsResponse = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent> __Method_SubscribeInstance = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent>(
@@ -92,6 +96,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
__Marshaller_sitestream_PullAuditEventsRequest,
__Marshaller_sitestream_PullAuditEventsResponse);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse> __Method_PullSiteCalls = new grpc::Method<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse>(
grpc::MethodType.Unary,
__ServiceName,
"PullSiteCalls",
__Marshaller_sitestream_PullSiteCallsRequest,
__Marshaller_sitestream_PullSiteCallsResponse);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
@@ -126,6 +138,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse> PullSiteCalls(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for SiteStreamService</summary>
@@ -225,6 +243,26 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
{
return CallInvoker.AsyncUnaryCall(__Method_PullAuditEvents, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse PullSiteCalls(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return PullSiteCalls(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse PullSiteCalls(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_PullSiteCalls, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse> PullSiteCallsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return PullSiteCallsAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse> PullSiteCallsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_PullSiteCalls, null, options, request);
}
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override SiteStreamServiceClient NewInstance(ClientBaseConfiguration configuration)
@@ -242,7 +280,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
.AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance)
.AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents)
.AddMethod(__Method_IngestCachedTelemetry, serviceImpl.IngestCachedTelemetry)
.AddMethod(__Method_PullAuditEvents, serviceImpl.PullAuditEvents).Build();
.AddMethod(__Method_PullAuditEvents, serviceImpl.PullAuditEvents)
.AddMethod(__Method_PullSiteCalls, serviceImpl.PullSiteCalls).Build();
}
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
@@ -256,6 +295,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(serviceImpl.IngestAuditEvents));
serviceBinder.AddMethod(__Method_IngestCachedTelemetry, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck>(serviceImpl.IngestCachedTelemetry));
serviceBinder.AddMethod(__Method_PullAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse>(serviceImpl.PullAuditEvents));
serviceBinder.AddMethod(__Method_PullSiteCalls, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse>(serviceImpl.PullSiteCalls));
}
}
@@ -1009,6 +1009,18 @@ akka {{
// direction one-way (Host knows both; Communication doesn't reach back
// into AuditLog).
grpcServer?.SetSiteAuditQueue(siteAuditQueue);
// Site Call Audit (#22): hand the site-local OperationTrackingStore to
// the gRPC server so the PullSiteCalls reconciliation RPC can serve
// central's self-heal pulls. siteTrackingStore is resolved above with
// GetService — present on site composition roots, null on central — so
// wire the seam only when the store exists. Like SetSiteAuditQueue, both
// the store and the gRPC server are singletons; wiring here keeps the
// dependency direction one-way (Host knows both; Communication doesn't
// reach back into SiteRuntime).
if (siteTrackingStore is not null)
{
grpcServer?.SetOperationTrackingStore(siteTrackingStore);
}
grpcServer?.SetReady(_actorSystem!);
}
}
@@ -360,6 +360,76 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SiteCallOperational>> ReadChangedSinceAsync(
DateTime sinceUtc,
int batchSize,
CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
// SiteRuntime-024: like GetStatusAsync, the reconciliation pull opens a
// fresh, ungated read connection so a long-running write never blocks
// central's PullSiteCalls. The query is a bounded, ordered scan over the
// (Status, UpdatedAtUtc) index range — UpdatedAtUtc is the cursor.
await using var readConnection = new SqliteConnection(_connectionString);
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 = """
SELECT TrackedOperationId, Kind, TargetSummary, Status,
RetryCount, LastError, HttpStatus,
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, SourceNode
FROM OperationTracking
WHERE UpdatedAtUtc >= $since
ORDER BY UpdatedAtUtc ASC
LIMIT $batchSize;
""";
cmd.Parameters.AddWithValue(
"$since",
sinceUtc.ToString("o", CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("$batchSize", batchSize);
var rows = new List<SiteCallOperational>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var kind = reader.GetString(1);
rows.Add(new SiteCallOperational(
TrackedOperationId: TrackedOperationId.Parse(reader.GetString(0)),
Channel: KindToChannel(kind),
Target: reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
// The site id is not a tracking-store column; the central client
// re-stamps SourceSite from the siteId it dialed.
SourceSite: string.Empty,
SourceNode: reader.IsDBNull(10) ? null : reader.GetString(10),
Status: reader.GetString(3),
RetryCount: reader.GetInt32(4),
LastError: reader.IsDBNull(5) ? null : reader.GetString(5),
HttpStatus: reader.IsDBNull(6) ? null : reader.GetInt32(6),
CreatedAtUtc: ParseUtc(reader.GetString(7)),
UpdatedAtUtc: ParseUtc(reader.GetString(8)),
TerminalAtUtc: reader.IsDBNull(9) ? null : ParseUtc(reader.GetString(9))));
}
return rows;
}
// Cached-call Kind → SiteCalls Channel. Only ApiCallCached / DbWriteCached
// ever reach the tracking store (RecordEnqueueAsync is the cached-call
// entry point); DbWriteCached maps to DbOutbound, everything else to the
// ApiOutbound default. Mirrors CachedCallLifecycleBridge's channel handling.
private static string KindToChannel(string kind) => kind switch
{
nameof(Commons.Types.Enums.AuditKind.DbWriteCached) => nameof(Commons.Types.Enums.AuditChannel.DbOutbound),
_ => nameof(Commons.Types.Enums.AuditChannel.ApiOutbound),
};
private static DateTime ParseUtc(string raw)
{
return DateTime.Parse(