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