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
@@ -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>