Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs
T
Joseph Doherty 5a878b78d4 docs(xml): fill missing XML doc comments + strip task-tracking refs across src (fixdocs)
Add missing <summary>/<param>/<returns>/<typeparam> tags and switch
interface implementations to <inheritdoc/> across 106 files; strip
project bookkeeping identifiers (Task NN, #05-TNN, PLAN-04, StoreAndForward-0NN)
from shipped code comments while preserving the descriptive rationale.
Comment-only: zero code-logic lines changed; solution builds 0/0.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 08:23:56 -04:00

1466 lines
66 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit;
/// <summary>
/// Central singleton for Site Call Audit. Receives
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/> row via
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
/// with <c>Accepted=true</c> in that case because storage state is consistent
/// and the site is free to consider its packet acked.
/// </summary>
/// <remarks>
/// <para>
/// Implemented: direct <see cref="UpsertSiteCallCommand"/> telemetry ingest,
/// query, detail and KPI handlers, the central→site Retry/Discard
/// relay (the relay handlers live in this actor), the periodic
/// per-site reconciliation puller that backfills lost telemetry (Piece A —
/// <see cref="OnReconciliationTickAsync"/>, the documented self-heal pull), and
/// the daily terminal-row purge scheduler (Piece B —
/// <see cref="OnPurgeTickAsync"/>, which invokes
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> on a timer). Both
/// background timers are started in <see cref="PreStart"/>, but on independent
/// preconditions. The purge timer is armed whenever
/// background timers are enabled (it needs only the repository, which every
/// production / reconciliation ctor always has) — it is NOT gated on the
/// reconciliation collaborators, so a host that registers Site Call Audit
/// without the reconciliation client still purges and the central
/// <c>SiteCalls</c> table cannot grow unbounded. The reconciliation timer
/// additionally requires its collaborators (<see cref="IPullSiteCallsClient"/> +
/// <see cref="ISiteEnumerator"/>) and logs a Warning when it cannot arm. The
/// repo-only MSSQL test ctor disables both timers (background timers off) so the
/// read/upsert tests see no scheduled side effects.
/// </para>
/// <para>
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
/// the actor catches every exception from the repository call and replies
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
/// alive. The <see cref="SupervisorStrategy"/> override governs the actor's
/// <em>children</em>, not the actor itself; this actor has no children today,
/// so the override is currently inert. It returns a one-for-one strategy with
/// <see cref="Akka.Actor.SupervisorStrategy.DefaultDecider"/> (Restart on most
/// exceptions, Stop on <see cref="ActorInitializationException"/> /
/// <see cref="ActorKilledException"/>) and <c>maxNrOfRetries: 0</c>, so any
/// future child that throws is Stopped on the first failure — a deliberate
/// "fail loudly" posture for the central singleton's eventual sub-actors
/// (reconciliation puller, purge scheduler). Self-supervision of this actor
/// is whatever the parent <see cref="Akka.Cluster.Tools.Singleton.ClusterSingletonManager"/>
/// supplies; the in-handler <c>try/catch</c> in <see cref="OnUpsertAsync"/>
/// is what actually keeps the singleton alive across repository faults.
/// </para>
/// <para>
/// Two constructors exist for the same reason as
/// <c>AuditLogIngestActor</c>: production wiring resolves the
/// scoped EF repository from a fresh DI scope per message because the actor
/// is a long-lived cluster singleton, while tests inject a concrete
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
/// so the actor exercises the real monotonic upsert SQL end to end.
/// </para>
/// </remarks>
public class SiteCallAuditActor : ReceiveActor
{
/// <summary>Maximum page size honoured by a <see cref="SiteCallQueryRequest"/>.</summary>
private const int MaxPageSize = 200;
/// <summary>
/// Hard ceiling on the number of <c>PullSiteCalls</c> RPCs
/// issued for a single site within ONE reconciliation tick when the site keeps
/// reporting <see cref="PullSiteCallsResponse.MoreAvailable"/>. Bounds the
/// within-tick continuation drain so a misbehaving site (or a pathological
/// single-timestamp saturation that pins the cursor) can never spin the
/// dispatcher unbounded; the remaining backlog drains on the next tick.
/// </summary>
private const int MaxReconciliationPagesPerTick = 50;
private readonly IServiceProvider? _serviceProvider;
private readonly ISiteCallAuditRepository? _injectedRepository;
private readonly SiteCallAuditOptions _options;
private readonly ILogger<SiteCallAuditActor> _logger;
/// <summary>
/// Central direct-write audit sink for operator Retry/Discard relay actions.
/// Null when no writer is registered (minimal hosts / most unit
/// tests) — in that case the relay emits no audit row and never reads the
/// <c>SiteCalls</c> mirror, preserving the "central touches no mirror row on
/// the relay path" behaviour. When present, a successful relay emits ONE
/// best-effort <c>CachedResolve</c> row recording the operator identity; the
/// site remains the source of truth for the state change itself.
/// </summary>
private readonly ICentralAuditWriter? _auditWriter;
/// <summary>
/// Reconciliation collaborators (Piece A). The per-site self-heal pull
/// (<see cref="IPullSiteCallsClient"/>) and the site list
/// (<see cref="ISiteEnumerator"/>). On the production path these are
/// resolved once from the root <see cref="IServiceProvider"/> (central
/// singletons registered by <c>AddAuditLogCentralReconciliationClient</c>);
/// in the test path they are injected directly. They are <c>null</c> when
/// the actor was built via the repo-only test ctor — in that case the
/// reconciliation tick is NOT started (see <see cref="StartReconciliationTimer"/>).
/// The purge tick, by contrast, does NOT depend on these collaborators:
/// it needs only the repository, so it is armed by
/// <see cref="_backgroundTimersEnabled"/> alone (see <see cref="StartPurgeTimer"/>).
/// </summary>
private readonly IPullSiteCallsClient? _pullClient;
private readonly ISiteEnumerator? _siteEnumerator;
/// <summary>
/// Master switch for the two background schedulers (reconciliation + purge),
/// set <c>true</c> by the production and reconciliation ctors and <c>false</c>
/// by the repo-only MSSQL test ctor. The purge timer is
/// gated on THIS flag rather than on the reconciliation collaborators, so a
/// host that omits the reconciliation client still purges (no unbounded
/// central <c>SiteCalls</c> growth) while the MSSQL read/upsert tests stay
/// free of any scheduled side effects.
/// </summary>
private readonly bool _backgroundTimersEnabled;
/// <summary>
/// Per-site reconciliation watermark — the composite
/// <c>(UpdatedAtUtc, TrackedOperationId)</c> keyset of the highest row seen
/// for that site on a previous tick. The next tick asks for rows
/// strictly greater than this pair, so a burst sharing one exact
/// <see cref="SiteCall.UpdatedAtUtc"/> drains via the id tiebreak rather than
/// pinning the timestamp forever; idempotent monotonic
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> still swallows any
/// boundary duplicate. In-memory for the singleton's lifetime — a
/// failover / restart resets every cursor to
/// <c>(DateTime.MinValue, null)</c>, which is conservative but correct (the
/// next tick re-pulls from the start and idempotent upsert dedupes). Mirrors
/// <c>SiteAuditReconciliationActor</c>'s cursor (now composite).
/// </summary>
private readonly Dictionary<string, (DateTime Since, string? AfterId)> _reconciliationCursors = new();
/// <summary>
/// Per-site "pinned" latch — <c>true</c> once a site's composite
/// cursor stopped advancing while the site still reported
/// <c>MoreAvailable=true</c> (in practice only a legacy site that ignores the
/// <c>after_id</c> keyset field). Tracks the latch so only <em>transitions</em>
/// publish <see cref="SiteCallReconciliationPinnedChanged"/> on the EventStream
/// (pinned on latch, unpinned when progress resumes), mirroring the sibling
/// <c>SiteAuditReconciliationActor</c>'s stalled-state machine — no flood of
/// redundant notifications.
/// </summary>
private readonly Dictionary<string, bool> _reconciliationPinned = new();
private ICancelable? _reconciliationTimer;
private ICancelable? _purgeTimer;
/// <summary>
/// The central→site command transport — the
/// <c>CentralCommunicationActor</c>, which owns the per-site
/// <c>ClusterClient</c> map and routes a <see cref="SiteEnvelope"/> to the
/// owning site. Set via <see cref="RegisterCentralCommunication"/> by the
/// Host after both actors exist (this actor is a cluster singleton; the
/// transport actor is created separately). Null until registration
/// completes — a relay arriving before then is answered with a
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome, because there
/// is genuinely no route to any site yet.
/// </summary>
private IActorRef? _centralCommunication;
/// <summary>
/// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance
/// across every message. Used by the MSSQL-backed TestKit fixture.
/// An optional <paramref name="options"/> lets a test pin the stuck/KPI
/// windows; when omitted the production defaults apply.
/// <para>
/// This ctor disables BOTH background timers (sets
/// <see cref="_backgroundTimersEnabled"/> to <c>false</c>) and injects no
/// reconciliation client/enumerator, so neither the reconciliation tick nor
/// the purge tick fires — the MSSQL-backed read/upsert tests must see no
/// scheduled side effects (no phantom pulls, no background purge).
/// </para>
/// </summary>
/// <param name="repository">Concrete repository instance to use for all messages.</param>
/// <param name="logger">Logger for diagnostics and error reporting.</param>
/// <param name="options">Optional configuration overrides; production defaults apply when null.</param>
/// <param name="auditWriter">Optional central direct-write audit sink for operator relay actions; null disables the relay audit row.</param>
public SiteCallAuditActor(
ISiteCallAuditRepository repository,
ILogger<SiteCallAuditActor> logger,
SiteCallAuditOptions? options = null,
ICentralAuditWriter? auditWriter = null)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository;
_logger = logger;
_options = options ?? new SiteCallAuditOptions();
_auditWriter = auditWriter;
// Repo-only MSSQL test ctor: keep BOTH background timers off so the
// read/upsert tests see no scheduled side effects.
_backgroundTimersEnabled = false;
RegisterHandlers();
}
/// <summary>
/// Test-mode constructor for the reconciliation tick (Piece A) — injects a
/// concrete repository PLUS the two reconciliation collaborators directly,
/// so the per-site self-heal pull is unit-testable in-memory without a DI
/// container or a live gRPC channel. Background timers are enabled, so the
/// purge tick starts (it needs only the repository) and the reconciliation
/// tick starts too because the client + enumerator are present — see
/// <see cref="StartReconciliationTimer"/> / <see cref="StartPurgeTimer"/>.
/// </summary>
/// <param name="repository">Concrete repository instance used for upserts and purges.</param>
/// <param name="siteEnumerator">Enumerates the sites to reconcile each tick.</param>
/// <param name="pullClient">Pull client used to fetch changed rows from each site.</param>
/// <param name="logger">Logger for diagnostics and error reporting.</param>
/// <param name="options">Optional configuration overrides; production defaults apply when null.</param>
/// <remarks>
/// Public (not internal) because Akka's default <c>ActivatorProducer</c>
/// instantiates the actor via reflection with public-only binding flags —
/// an internal ctor yields a <c>MissingMethodException</c> at actor
/// creation. Distinguished from the production <see cref="IServiceProvider"/>
/// ctor by its concrete-collaborator parameter list; only the test project
/// (or a host that hand-resolves the collaborators) constructs it this way.
/// </remarks>
public SiteCallAuditActor(
ISiteCallAuditRepository repository,
ISiteEnumerator siteEnumerator,
IPullSiteCallsClient pullClient,
ILogger<SiteCallAuditActor> logger,
SiteCallAuditOptions? options = null)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(siteEnumerator);
ArgumentNullException.ThrowIfNull(pullClient);
ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository;
_siteEnumerator = siteEnumerator;
_pullClient = pullClient;
_logger = logger;
_options = options ?? new SiteCallAuditOptions();
// Reconciliation test ctor: collaborators present, so both timers arm
// (the reconciliation tick uses the collaborators; the purge tick needs
// only the repository).
_backgroundTimersEnabled = true;
RegisterHandlers();
}
/// <summary>
/// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
/// from a fresh DI scope per message because the repository is a scoped EF
/// Core service registered by <c>AddConfigurationDatabase</c>. The actor
/// itself is a long-lived cluster singleton, so it cannot hold a scope
/// across messages.
/// </summary>
/// <param name="serviceProvider">DI service provider used to create a scoped repository per message.</param>
/// <param name="options">Actor configuration (stuck threshold, KPI interval, relay timeout).</param>
/// <param name="logger">Logger for diagnostics and error reporting.</param>
public SiteCallAuditActor(
IServiceProvider serviceProvider,
SiteCallAuditOptions options,
ILogger<SiteCallAuditActor> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_serviceProvider = serviceProvider;
_options = options;
_logger = logger;
// Central direct-write audit sink for operator relay actions.
// Resolved once from the root provider — ICentralAuditWriter is a central
// singleton (registered by AuditLog). Null-safe: a host that omits it just
// relays without emitting the operator-identity row.
_auditWriter = serviceProvider.GetService<ICentralAuditWriter>();
// Reconciliation collaborators (Piece A) are central-only singletons
// registered by AddAuditLogCentralReconciliationClient — always on the
// central composition root (Program.cs). Resolve them once here (the
// actor itself is a long-lived singleton; the repository is the only
// scoped service and is still resolved per-tick/per-message). GetService
// (not GetRequiredService) so a host that somehow omits the helper
// degrades to "no reconciliation tick" rather than a startup crash —
// the tick startup gates on both being non-null.
_pullClient = serviceProvider.GetService<IPullSiteCallsClient>();
_siteEnumerator = serviceProvider.GetService<ISiteEnumerator>();
// Production path: background timers run. The purge tick is armed
// unconditionally here (it needs only the repository); the reconciliation
// tick additionally requires its collaborators and logs a Warning if
// they were not registered.
_backgroundTimersEnabled = true;
RegisterHandlers();
}
/// <summary>
/// Wires up the message handlers shared by both constructors: the
/// ingest path plus the read-side (query, detail, global + per-site
/// KPI). All read handlers reply to an Ask, so they capture <c>Sender</c>
/// before the first await and <c>PipeTo</c> the result back.
/// </summary>
private void RegisterHandlers()
{
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
Receive<SiteCallQueryRequest>(HandleQuery);
Receive<SiteCallDetailRequest>(HandleDetail);
Receive<SiteCallKpiRequest>(HandleKpi);
Receive<PerSiteSiteCallKpiRequest>(HandlePerSiteKpi);
Receive<PerNodeSiteCallKpiRequest>(HandlePerNodeKpi);
// Central→site Retry/Discard relay for parked cached calls.
Receive<RegisterCentralCommunication>(msg =>
{
_centralCommunication = msg.CentralCommunication;
_logger.LogInformation("SiteCallAudit registered central→site communication transport");
});
Receive<RetrySiteCallRequest>(HandleRetrySiteCall);
Receive<DiscardSiteCallRequest>(HandleDiscardSiteCall);
// Piece A/B: self-ticks for the periodic reconciliation pull and
// the daily terminal-row purge. Handlers stay alive across faults via
// their own per-site / per-tick try/catch (mirroring the ingest path);
// the timers are only started when their collaborators are available.
ReceiveAsync<ReconciliationTick>(_ => OnReconciliationTickAsync());
ReceiveAsync<PurgeTick>(_ => OnPurgeTickAsync());
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
StartReconciliationTimer();
StartPurgeTimer();
}
/// <inheritdoc />
protected override void PostStop()
{
_reconciliationTimer?.Cancel();
_purgeTimer?.Cancel();
base.PostStop();
}
/// <summary>
/// Starts the periodic reconciliation tick — but ONLY when background timers
/// are enabled AND both the pull client and the site enumerator are
/// available. The repo-only test ctor disables background timers, so the tick
/// is gated off there (the MSSQL read/upsert tests must not fire phantom
/// pulls); the reconciliation test ctor and the production ctor (which
/// resolves both from the SP) start it. When background
/// timers are enabled but the collaborators were not registered, log a
/// Warning so a misconfigured host surfaces the missing self-heal rather than
/// silently skipping it.
/// </summary>
private void StartReconciliationTimer()
{
if (!_backgroundTimersEnabled)
{
return;
}
if (_pullClient is null || _siteEnumerator is null)
{
_logger.LogWarning(
"SiteCallAudit reconciliation timer not started — the reconciliation "
+ "collaborators (IPullSiteCallsClient / ISiteEnumerator) were not registered; "
+ "lost cached-call telemetry will not self-heal until they are wired up. "
+ "The daily terminal-row purge still runs.");
return;
}
var interval = _options.ResolvedReconciliationInterval;
_reconciliationTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(
initialDelay: interval,
interval: interval,
receiver: Self,
message: ReconciliationTick.Instance,
sender: Self);
}
/// <summary>
/// Starts the daily purge tick. The purge needs ONLY the
/// repository — never the reconciliation collaborators — so it is gated on
/// <see cref="_backgroundTimersEnabled"/> alone, NOT on
/// <see cref="_pullClient"/> / <see cref="_siteEnumerator"/>. This decouples
/// the daily terminal-row purge from the reconciliation client: a host that
/// registers Site Call Audit without the reconciliation client still purges,
/// so the central <c>SiteCalls</c> table can never grow unbounded just
/// because the self-heal puller is absent. Only the repo-only MSSQL test ctor
/// (background timers off) skips it, keeping the read/upsert tests free of
/// scheduled side effects.
/// </summary>
private void StartPurgeTimer()
{
if (!_backgroundTimersEnabled)
{
return;
}
var interval = _options.ResolvedPurgeInterval;
_purgeTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(
// Short first tick (min(interval, 5min)): purges are idempotent, and a full-interval
// initial delay meant a daily-recycled node never purged (arch-review 04, S9). Fast
// test cadences (interval < 5min) are unchanged.
initialDelay: PurgeTimerSchedule.InitialDelay(interval),
interval: interval,
receiver: Self,
message: PurgeTick.Instance,
sender: Self);
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
Akka.Actor.SupervisorStrategy.DefaultDecider);
}
private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
{
// Sender is captured before the first await — Akka resets Sender
// between message dispatches, so a post-await Tell would go to
// DeadLetters.
var replyTo = Sender;
var id = cmd.SiteCall.TrackedOperationId;
// Scope-per-message mirrors AuditLogIngestActor — production EF
// repository is scoped; the injected-repository mode (tests) skips
// the scope entirely.
IServiceScope? scope = null;
ISiteCallAuditRepository repository;
if (_injectedRepository is not null)
{
repository = _injectedRepository;
}
else
{
scope = _serviceProvider!.CreateScope();
repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
}
try
{
// Stamp IngestedAtUtc at central-side persist
// time on every upsert, mirroring AuditLogIngestActor's combined-
// telemetry hot path. IngestedAtUtc is the "central ingested (or
// last refreshed) this row" timestamp; callers (telemetry,
// future reconciliation puller, direct-writes) cannot in general
// know they are running on central, so the actor owns the stamp.
var siteCall = cmd.SiteCall with { IngestedAtUtc = DateTime.UtcNow };
await repository.UpsertAsync(siteCall).ConfigureAwait(false);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
}
catch (Exception ex)
{
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
// action — log and reply Accepted=false; do NOT rethrow (the
// central singleton MUST stay alive).
_logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
}
finally
{
scope?.Dispose();
}
}
// ── Piece A: periodic per-site reconciliation pull (self-heal) ──
/// <summary>
/// One reconciliation pass: enumerate every known site and, per site, pull
/// changed <see cref="SiteCall"/> rows since that site's cursor and upsert
/// them idempotently — the documented self-heal when best-effort gRPC push
/// telemetry is lost. This is a mirror, NOT a dispatcher: cached-call
/// delivery stays site-local; upserting reconciled rows only refreshes the
/// eventually-consistent central <c>SiteCalls</c> mirror.
/// </summary>
/// <remarks>
/// Mirrors <c>SiteAuditReconciliationActor</c>'s structure (per-site cursor,
/// per-site try/catch failure isolation, advance the cursor by the max
/// observed <see cref="SiteCall.UpdatedAtUtc"/>) but is deliberately simpler:
/// no stalled-detection EventStream machinery — just cursor + pull + upsert
/// + advance. One DI scope per tick is opened and the same repository reused
/// across every site in that tick.
/// </remarks>
private async Task OnReconciliationTickAsync()
{
// The collaborators are guaranteed non-null: the tick is only scheduled
// when both are present (StartReconciliationTimer). Assert via the
// local copies so a future refactor that drops the gate fails loudly.
var enumerator = _siteEnumerator!;
var client = _pullClient!;
IReadOnlyList<SiteEntry> sites;
try
{
// No ambient CancellationToken in a ReceiveActor handler — None is
// intentional; the work is bounded by the reconciliation interval
// plus the singleton's graceful-stop drain on PhaseClusterLeave.
sites = await enumerator.EnumerateAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "SiteCallAudit site enumeration failed; skipping reconciliation tick.");
return;
}
if (sites.Count == 0)
{
return;
}
// Open the scope INLINE with CreateAsyncScope + await using
// so the scoped EF Core repository (an IAsyncDisposable DbContext) disposes
// asynchronously at end of tick rather than blocking the Akka dispatcher
// thread on a synchronous Dispose() of pending connection cleanup — the tick
// holds the scope across many awaited UpsertAsync calls. Mirrors the sibling
// SiteAuditReconciliationActor.OnTickAsync. ResolveRepository() (sync Dispose)
// is retained for the synchronous message-handler paths. In the injected-
// repository test path there is no scope to open and the test repo is reused.
if (_injectedRepository is not null)
{
await ReconcileSitesAsync(sites, client, _injectedRepository).ConfigureAwait(false);
return;
}
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
await ReconcileSitesAsync(sites, client, repository).ConfigureAwait(false);
}
/// <summary>
/// Reconciles every site in the tick against a single resolved repository,
/// isolating per-site faults so one bad site never sinks the rest of the
/// pass (the failing site's cursor is left at its previous value so the next
/// tick retries the same window).
/// </summary>
private async Task ReconcileSitesAsync(
IReadOnlyList<SiteEntry> sites, IPullSiteCallsClient client, ISiteCallAuditRepository repository)
{
foreach (var site in sites)
{
try
{
await ReconcileSiteAsync(site, client, repository).ConfigureAwait(false);
}
catch (Exception ex)
{
// Failure-isolation invariant: one site's fault (transport,
// repository write) must NOT sink the rest of the tick. The
// failing site's cursor is left at its previous value so the
// next tick retries the same window.
_logger.LogWarning(
ex,
"SiteCallAudit reconciliation pull failed for site {SiteId}; other sites continue.",
site.SiteId);
}
}
}
/// <summary>
/// Issues one <c>PullSiteCalls</c> RPC against the site, upserts the
/// returned rows idempotently, and advances the site's cursor to the maximum
/// <see cref="SiteCall.UpdatedAtUtc"/> observed. The pull client returns rows
/// oldest-first with <c>SourceSite</c> already re-stamped from the dialed
/// site id, so the actor upserts them verbatim (re-stamping
/// <c>IngestedAtUtc</c> at central persist time, as the telemetry path does).
/// </summary>
/// <remarks>
/// <para>
/// <b>Coarse per-site retry — a deliberate divergence from
/// <c>SiteAuditReconciliationActor</c>.</b> That sibling tracks
/// a per-EventId attempt counter and permanently abandons a row after a
/// threshold so a single un-insertable row cannot block a site's cursor
/// forever. This actor deliberately does NOT: any throw inside the loop
/// propagates to <see cref="OnReconciliationTickAsync"/>'s per-site catch,
/// which leaves the site's cursor at its previous value, so the next tick
/// re-pulls the whole batch from <c>since</c>. A persistently-bad row therefore
/// holds the site's cursor and re-pulls the batch every tick. This is
/// acceptable here because <see cref="ISiteCallAuditRepository.UpsertAsync"/> is
/// monotonic and idempotent — re-pulling already-ingested rows is a cheap
/// no-op — and the <c>SiteCalls</c> table is an eventually-consistent mirror,
/// not the source of truth, so a slow site simply lags rather than corrupts.
/// </para>
/// <para>
/// <b>Composite keyset cursor.</b> The cursor is the
/// <c>(UpdatedAtUtc, TrackedOperationId)</c> pair of the maximum row seen, and
/// the pull asks for rows strictly greater than it. The boundary row is NOT
/// re-pulled (unlike the prior inclusive-timestamp cursor), and — critically —
/// a burst of more than <see cref="SiteCallAuditOptions.ReconciliationBatchSize"/>
/// rows sharing one exact <see cref="SiteCall.UpdatedAtUtc"/> now drains via the
/// id tiebreak instead of pinning the timestamp forever. The idempotent monotonic
/// upsert still dedupes any overlap. Matches the composite keyset the site's
/// <c>ReadChangedSinceAsync</c> honours.
/// </para>
/// <para>
/// <b>Consumes <see cref="PullSiteCallsResponse.MoreAvailable"/>
/// to guarantee forward progress.</b> The method continues pulling within the
/// same tick while the site reports <c>MoreAvailable=true</c>, bounded by
/// <see cref="MaxReconciliationPagesPerTick"/> so a misbehaving site can never
/// spin the dispatcher. Each page advances the in-flight composite cursor. If a
/// page fails to advance the cursor yet the site still reports more available,
/// the site is NOT honouring the <c>after_id</c> keyset (a legacy site that
/// predates the composite keyset): continuing would re-pull the identical window forever, so the loop
/// latches the site as <em>pinned</em> and publishes
/// <see cref="SiteCallReconciliationPinnedChanged"/> on the EventStream — the
/// dead-end becomes a health-observable condition rather than a silent log line.
/// When a later tick makes progress the latch clears with <c>Pinned=false</c>.
/// This aligns with <c>SiteAuditReconciliationActor</c>'s
/// <c>SiteAuditTelemetryStalledChanged</c> transition-only publication.
/// </para>
/// </remarks>
private async Task ReconcileSiteAsync(
SiteEntry site, IPullSiteCallsClient client, ISiteCallAuditRepository repository)
{
var cursor = _reconciliationCursors.TryGetValue(site.SiteId, out var c)
? c
: (Since: DateTime.MinValue, AfterId: (string?)null);
// Drain within the tick while the site keeps reporting
// MoreAvailable, bounded by MaxReconciliationPagesPerTick so a misbehaving
// site can never spin the dispatcher. Each page advances the in-flight
// composite cursor; a page that fails to advance it while MoreAvailable is
// still true means the site ignores after_id (legacy) — latch + publish
// the pinned state and break.
for (var page = 0; page < MaxReconciliationPagesPerTick; page++)
{
var since = cursor.Since;
var afterId = cursor.AfterId;
var response = await client
.PullAsync(site.SiteId, since, afterId, _options.ReconciliationBatchSize, CancellationToken.None)
.ConfigureAwait(false);
var maxUpdated = since;
var maxAfterId = afterId;
var nowUtc = DateTime.UtcNow;
foreach (var row in response.SiteCalls)
{
// IngestedAtUtc is the "central ingested (or last refreshed) this
// row" stamp — owned by the central actor, exactly as OnUpsertAsync
// does for the telemetry path. Monotonic UpsertAsync makes a row
// already present (from a prior push) a silent no-op.
var siteCall = row with { IngestedAtUtc = nowUtc };
await repository.UpsertAsync(siteCall).ConfigureAwait(false);
// Advance the composite max: greater timestamp wins; on a tie the
// greater TrackedOperationId (ordinal, matching the site's SQLite
// BINARY text collation) wins.
var rowId = row.TrackedOperationId.ToString();
if (row.UpdatedAtUtc > maxUpdated ||
(row.UpdatedAtUtc == maxUpdated && string.CompareOrdinal(rowId, maxAfterId) > 0))
{
maxUpdated = row.UpdatedAtUtc;
maxAfterId = rowId;
}
}
// Did the composite cursor move? (timestamp advanced OR — at the same
// timestamp — the id tiebreak advanced). CompareOrdinal handles nulls
// (CompareOrdinal(x, null) > 0 for any non-null x).
var advanced = maxUpdated > since || string.CompareOrdinal(maxAfterId, afterId) != 0;
// Persist the advanced cursor after every page so a fault on a later
// page (caught per-site upstream) still keeps the rows already drained.
cursor = (maxUpdated, maxAfterId);
_reconciliationCursors[site.SiteId] = cursor;
// Progress resumed → clear any prior pinned latch (publishes the
// Pinned=false transition exactly once).
if (advanced)
{
SetReconciliationPinned(site.SiteId, pinned: false);
}
if (!response.MoreAvailable)
{
// Backlog fully drained for this site this tick.
return;
}
if (!advanced)
{
// The composite cursor did not advance yet the site still reports
// MoreAvailable — the site ignored after_id (a legacy pre-keyset
// site), so a continuation would re-pull the identical window
// forever. Latch + publish the pinned state and stop; the next tick
// re-checks and unpins if the site catches up.
SetReconciliationPinned(site.SiteId, pinned: true);
_logger.LogWarning(
"SiteCallAudit reconciliation for site {SiteId} is pinned: the composite "
+ "(UpdatedAtUtc, TrackedOperationId) cursor did not advance past ({CursorUtc:o}, {AfterId}) "
+ "yet the site reports more rows available — the site does not honour the after_id keyset "
+ "(legacy). The backlog tail will not reconcile until the site is upgraded.",
site.SiteId,
since,
afterId ?? "(none)");
return;
}
}
// Hit the within-tick page ceiling while MoreAvailable was still true:
// the cursor advanced each page (so the backlog IS draining), there is
// simply more than MaxReconciliationPagesPerTick × batch-size of it. The
// next tick resumes from the advanced cursor.
_logger.LogInformation(
"SiteCallAudit reconciliation for site {SiteId} hit the per-tick page ceiling "
+ "({MaxPages} pages); the cursor advanced each page and the remaining backlog "
+ "drains on the next tick.",
site.SiteId,
MaxReconciliationPagesPerTick);
}
/// <summary>
/// Latches the per-site reconciliation pinned state and publishes
/// <see cref="SiteCallReconciliationPinnedChanged"/> on the actor system
/// EventStream ONLY on a transition — mirroring
/// <c>SiteAuditReconciliationActor.UpdateStalledState</c> so a downstream
/// health subscriber never sees a flood of same-state notifications.
/// </summary>
private void SetReconciliationPinned(string siteId, bool pinned)
{
var was = _reconciliationPinned.TryGetValue(siteId, out var prior) && prior;
if (was == pinned)
{
return;
}
_reconciliationPinned[siteId] = pinned;
Context.System.EventStream.Publish(new SiteCallReconciliationPinnedChanged(siteId, pinned));
}
// ── Piece B: daily terminal-row purge scheduler ──
/// <summary>
/// One purge pass: drops terminal <c>SiteCalls</c> rows whose
/// <see cref="SiteCall.TerminalAtUtc"/> is older than
/// <c>UtcNow - RetentionDays</c> via
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/>. Non-terminal
/// rows are never purged (enforced in the repository). The threshold is
/// computed each tick so an operator who lowers <c>RetentionDays</c> sees it
/// applied on the next purge without an actor restart. Mirrors
/// <c>AuditLogPurgeActor</c>'s daily cadence + continue-on-error posture: a
/// purge fault is logged and swallowed so the singleton stays alive.
/// </summary>
private async Task OnPurgeTickAsync()
{
var threshold = DateTime.UtcNow - TimeSpan.FromDays(_options.RetentionDays);
// Open the scope INLINE with CreateAsyncScope + await using
// so the scoped EF Core repository (an IAsyncDisposable DbContext) disposes
// asynchronously rather than blocking the Akka dispatcher thread on a
// synchronous Dispose(). Mirrors SiteAuditReconciliationActor; the
// injected-repository test path reuses the test repo with no scope.
if (_injectedRepository is not null)
{
await PurgeWithRepositoryAsync(_injectedRepository, threshold).ConfigureAwait(false);
return;
}
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
await PurgeWithRepositoryAsync(repository, threshold).ConfigureAwait(false);
}
/// <summary>
/// Runs one terminal-row purge against the resolved repository, logging and
/// swallowing any fault (continue-on-error) so a transient SQL failure or
/// contention never crashes the central singleton — the next tick retries
/// the same window.
/// </summary>
private async Task PurgeWithRepositoryAsync(ISiteCallAuditRepository repository, DateTime threshold)
{
try
{
var rowsDeleted = await repository.PurgeTerminalAsync(threshold).ConfigureAwait(false);
if (rowsDeleted > 0)
{
_logger.LogInformation(
"SiteCallAudit purged {RowsDeleted} terminal SiteCalls rows older than {ThresholdUtc:o}.",
rowsDeleted,
threshold);
}
}
catch (Exception ex)
{
// Continue-on-error: a purge fault (transient SQL failure,
// contention) must NOT crash the central singleton. The next tick
// retries the same window.
_logger.LogError(
ex,
"SiteCallAudit terminal-row purge failed (threshold {ThresholdUtc:o}); will retry next tick.",
threshold);
}
}
// ── Read-side (query / detail / KPI) ──
/// <summary>
/// Handles a paginated, filtered query over the <c>SiteCalls</c> table.
/// Builds a <see cref="SiteCallQueryFilter"/> + <see cref="SiteCallPaging"/>
/// keyset cursor from the request, runs the query on a scoped repository,
/// and pipes the mapped response back to the captured sender. A repository
/// fault yields a failure response with an empty list.
/// </summary>
private void HandleQuery(SiteCallQueryRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
QueryAsync(request, now).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallQueryResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
SiteCalls: Array.Empty<SiteCallSummary>(),
NextAfterCreatedAtUtc: null,
NextAfterId: null));
}
private async Task<SiteCallQueryResponse> QueryAsync(SiteCallQueryRequest request, DateTime now)
{
var stuckCutoff = now - _options.StuckAgeThreshold;
var filter = new SiteCallQueryFilter(
Channel: NullIfBlank(request.ChannelFilter),
SourceSite: NullIfBlank(request.SourceSiteFilter),
Status: NullIfBlank(request.StatusFilter),
Target: NullIfBlank(request.TargetKeyword),
FromUtc: request.FromUtc,
ToUtc: request.ToUtc,
// StuckOnly is pushed into the repository SQL via StuckCutoffUtc —
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
// keyset cursor, so the page is always honest (full pages, no empty
// pages with a non-null next cursor).
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
SourceNode: NullIfBlank(request.SourceNodeFilter));
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
var paging = new SiteCallPaging(
PageSize: pageSize,
AfterCreatedAtUtc: request.AfterCreatedAtUtc,
AfterId: request.AfterId is { } id ? new TrackedOperationId(id) : null);
var (scope, repository) = ResolveRepository();
try
{
var rows = await repository.QueryAsync(filter, paging).ConfigureAwait(false);
var summaries = rows
.Select(row => ToSummary(row, stuckCutoff))
.ToList();
// The next-page cursor is the last row of the materialised page.
var cursorRow = rows.Count > 0 ? rows[^1] : null;
return new SiteCallQueryResponse(
request.CorrelationId,
Success: true,
ErrorMessage: null,
SiteCalls: summaries,
NextAfterCreatedAtUtc: cursorRow?.CreatedAtUtc,
NextAfterId: cursorRow?.TrackedOperationId.Value);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a full-detail query for a single cached call — backs the report
/// detail modal. A missing row yields <c>Success=false</c> with a "not
/// found" message; a repository fault yields <c>Success=false</c> with the
/// fault message.
/// </summary>
private void HandleDetail(SiteCallDetailRequest request)
{
var sender = Sender;
DetailAsync(request).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallDetailResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Detail: null));
}
private async Task<SiteCallDetailResponse> DetailAsync(SiteCallDetailRequest request)
{
var (scope, repository) = ResolveRepository();
try
{
var row = await repository
.GetAsync(new TrackedOperationId(request.TrackedOperationId))
.ConfigureAwait(false);
if (row is null)
{
return new SiteCallDetailResponse(
request.CorrelationId,
Success: false,
ErrorMessage: "site call not found",
Detail: null);
}
return new SiteCallDetailResponse(
request.CorrelationId,
Success: true,
ErrorMessage: null,
Detail: ToDetail(row));
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a global KPI snapshot request, deriving the stuck cutoff from
/// <see cref="SiteCallAuditOptions.StuckAgeThreshold"/> and the
/// failed/delivered interval bound from <see cref="SiteCallAuditOptions.KpiInterval"/>.
/// </summary>
private void HandleKpi(SiteCallKpiRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
var stuckCutoff = now - _options.StuckAgeThreshold;
var intervalSince = now - _options.KpiInterval;
KpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
BufferedCount: 0,
ParkedCount: 0,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: 0));
}
private async Task<SiteCallKpiResponse> KpiAsync(
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
{
var (scope, repository) = ResolveRepository();
try
{
var snapshot = await repository
.ComputeKpisAsync(stuckCutoff, intervalSince)
.ConfigureAwait(false);
return new SiteCallKpiResponse(
correlationId,
Success: true,
ErrorMessage: null,
snapshot.BufferedCount,
snapshot.ParkedCount,
snapshot.FailedLastInterval,
snapshot.DeliveredLastInterval,
snapshot.OldestPendingAge,
snapshot.StuckCount);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a per-source-site KPI request, using the same stuck cutoff and
/// interval bound as <see cref="HandleKpi"/>.
/// </summary>
private void HandlePerSiteKpi(PerSiteSiteCallKpiRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
var stuckCutoff = now - _options.StuckAgeThreshold;
var intervalSince = now - _options.KpiInterval;
PerSiteKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
sender,
success: response => response,
failure: ex => new PerSiteSiteCallKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Sites: Array.Empty<SiteCallSiteKpiSnapshot>()));
}
private async Task<PerSiteSiteCallKpiResponse> PerSiteKpiAsync(
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
{
var (scope, repository) = ResolveRepository();
try
{
var sites = await repository
.ComputePerSiteKpisAsync(stuckCutoff, intervalSince)
.ConfigureAwait(false);
return new PerSiteSiteCallKpiResponse(
correlationId, Success: true, ErrorMessage: null, sites);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a per-node KPI request, using the same stuck cutoff and
/// interval bound as <see cref="HandleKpi"/>. Additive alongside
/// <see cref="HandlePerSiteKpi"/> — does not change per-site behaviour.
/// </summary>
private void HandlePerNodeKpi(PerNodeSiteCallKpiRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
var stuckCutoff = now - _options.StuckAgeThreshold;
var intervalSince = now - _options.KpiInterval;
PerNodeKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
sender,
success: response => response,
failure: ex => new PerNodeSiteCallKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Nodes: Array.Empty<SiteCallNodeKpiSnapshot>()));
}
private async Task<PerNodeSiteCallKpiResponse> PerNodeKpiAsync(
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
{
var (scope, repository) = ResolveRepository();
try
{
var nodes = await repository
.ComputePerNodeKpisAsync(stuckCutoff, intervalSince)
.ConfigureAwait(false);
return new PerNodeSiteCallKpiResponse(
correlationId, Success: true, ErrorMessage: null, nodes);
}
finally
{
scope?.Dispose();
}
}
// ── Central→site Retry/Discard relay ──
/// <summary>
/// Relays an operator Retry of a parked cached call to its owning site. The
/// site is the source of truth — this handler NEVER writes the central
/// <c>SiteCalls</c> mirror row. It wraps a <see cref="RetryParkedOperation"/>
/// in a <see cref="SiteEnvelope"/> addressed to <c>SourceSite</c>, Asks the
/// <c>CentralCommunicationActor</c> (which routes it over the per-site
/// <c>ClusterClient</c>), and maps the site's
/// <see cref="ParkedOperationActionAck"/> — or an Ask timeout — onto a
/// <see cref="RetrySiteCallResponse"/>. A timeout / no-route is reported as
/// the distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome,
/// not a generic failure, so the Central UI can tell "site offline" from
/// "operation failed".
/// </summary>
private void HandleRetrySiteCall(RetrySiteCallRequest request)
{
var sender = Sender;
if (_centralCommunication is null)
{
// No transport registered yet — there is genuinely no route to any
// site, so the only honest answer is unreachable.
_logger.LogWarning(
"RetrySiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
+ "central→site transport was registered; reporting site unreachable",
request.TrackedOperationId, request.SourceSite);
sender.Tell(UnreachableRetry(request.CorrelationId));
return;
}
var relay = new RetryParkedOperation(
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
RelayRetryAsync(request, envelope).PipeTo(sender);
}
/// <summary>
/// Awaits the site's ack for a Retry relay, maps it onto the response, and —
/// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
/// present — emits ONE best-effort operator-identity audit row. The
/// relay outcome is authoritative; the audit write never changes it.
/// </summary>
private async Task<RetrySiteCallResponse> RelayRetryAsync(RetrySiteCallRequest request, SiteEnvelope envelope)
{
ParkedOperationActionAck ack;
try
{
ack = await _centralCommunication!
.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.ConfigureAwait(false);
}
catch (Exception ex)
{
return MapRetryFailure(request.CorrelationId, request.SourceSite, ex);
}
var response = MapRetryResponse(request.CorrelationId, ack);
if (response.Outcome == SiteCallRelayOutcome.Applied)
{
await EmitRelayAuditAsync(
request.TrackedOperationId, request.RequestedBy, AuditStatus.Submitted).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Relays an operator Discard of a parked cached call to its owning site.
/// Mirrors <see cref="HandleRetrySiteCall"/> — see that method for the
/// source-of-truth and site-unreachable rationale.
/// </summary>
private void HandleDiscardSiteCall(DiscardSiteCallRequest request)
{
var sender = Sender;
if (_centralCommunication is null)
{
_logger.LogWarning(
"DiscardSiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
+ "central→site transport was registered; reporting site unreachable",
request.TrackedOperationId, request.SourceSite);
sender.Tell(UnreachableDiscard(request.CorrelationId));
return;
}
var relay = new DiscardParkedOperation(
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
RelayDiscardAsync(request, envelope).PipeTo(sender);
}
/// <summary>
/// Awaits the site's ack for a Discard relay, maps it onto the response, and —
/// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
/// present — emits ONE best-effort operator-identity audit row.
/// </summary>
private async Task<DiscardSiteCallResponse> RelayDiscardAsync(DiscardSiteCallRequest request, SiteEnvelope envelope)
{
ParkedOperationActionAck ack;
try
{
ack = await _centralCommunication!
.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.ConfigureAwait(false);
}
catch (Exception ex)
{
return MapDiscardFailure(request.CorrelationId, request.SourceSite, ex);
}
var response = MapDiscardResponse(request.CorrelationId, ack);
if (response.Outcome == SiteCallRelayOutcome.Applied)
{
await EmitRelayAuditAsync(
request.TrackedOperationId, request.RequestedBy, AuditStatus.Discarded).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Emits one best-effort central direct-write <see cref="AuditKind.CachedResolve"/>
/// row attributing an operator Retry/Discard relay to <paramref name="actor"/>. The
/// audited <see cref="AuditChannel"/>, target, source site and node are read from the
/// stored <c>SiteCalls</c> mirror row (a benign READ — central still never mutates the
/// mirror). No-op when no <see cref="ICentralAuditWriter"/> is registered. A missing
/// row or a writer fault is swallowed and logged — the relay outcome is authoritative.
/// </summary>
private async Task EmitRelayAuditAsync(Guid trackedOperationId, string? actor, AuditStatus status)
{
if (_auditWriter is null)
{
return;
}
var (scope, repository) = ResolveRepository();
try
{
var row = await repository
.GetAsync(new TrackedOperationId(trackedOperationId))
.ConfigureAwait(false);
// SiteCall.Channel is the string form of an AuditChannel ("ApiOutbound"/
// "DbOutbound"); fall back to ApiOutbound if the row is missing or the
// string is unrecognised so the row still records who asked.
var channel = row is not null && Enum.TryParse<AuditChannel>(row.Channel, out var parsed)
? parsed
: AuditChannel.ApiOutbound;
var evt = ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: AuditKind.CachedResolve,
status: status,
occurredAtUtc: DateTime.UtcNow,
actor: actor,
target: row?.Target,
sourceNode: row?.SourceNode,
correlationId: trackedOperationId,
sourceSiteId: row?.SourceSite);
await _auditWriter.WriteAsync(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to emit operator relay audit row for {TrackedOperationId}.",
trackedOperationId);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Maps the site's <see cref="ParkedOperationActionAck"/> for a Retry onto a
/// <see cref="RetrySiteCallResponse"/>: an applied action is
/// <see cref="SiteCallRelayOutcome.Applied"/>; a clean no-op
/// (<c>Applied=false</c>, no error) is <see cref="SiteCallRelayOutcome.NotParked"/>;
/// an ack carrying an error is <see cref="SiteCallRelayOutcome.OperationFailed"/>
/// — in every case the site WAS reached.
/// </summary>
private static RetrySiteCallResponse MapRetryResponse(string correlationId, ParkedOperationActionAck ack)
{
var outcome = ClassifyAck(ack);
return new RetrySiteCallResponse(
correlationId,
outcome,
Success: outcome == SiteCallRelayOutcome.Applied,
SiteReachable: true,
ErrorMessage: AckErrorMessage(outcome, ack));
}
private static DiscardSiteCallResponse MapDiscardResponse(string correlationId, ParkedOperationActionAck ack)
{
var outcome = ClassifyAck(ack);
return new DiscardSiteCallResponse(
correlationId,
outcome,
Success: outcome == SiteCallRelayOutcome.Applied,
SiteReachable: true,
ErrorMessage: AckErrorMessage(outcome, ack));
}
private RetrySiteCallResponse MapRetryFailure(string correlationId, string sourceSite, Exception ex)
{
_logger.LogWarning(ex,
"Retry relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
return UnreachableRetry(correlationId);
}
private DiscardSiteCallResponse MapDiscardFailure(string correlationId, string sourceSite, Exception ex)
{
_logger.LogWarning(ex,
"Discard relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
return UnreachableDiscard(correlationId);
}
/// <summary>
/// Classifies a site ack: <c>Applied=true</c> → applied; <c>Applied=false</c>
/// with no error → the site definitively had nothing parked; <c>Applied=false</c>
/// with an error → the site could not apply the action.
/// </summary>
private static SiteCallRelayOutcome ClassifyAck(ParkedOperationActionAck ack)
{
if (ack.Applied)
{
return SiteCallRelayOutcome.Applied;
}
return ack.ErrorMessage is null
? SiteCallRelayOutcome.NotParked
: SiteCallRelayOutcome.OperationFailed;
}
private static string? AckErrorMessage(SiteCallRelayOutcome outcome, ParkedOperationActionAck ack)
{
return outcome switch
{
SiteCallRelayOutcome.Applied => null,
SiteCallRelayOutcome.NotParked =>
"The operation is no longer parked at the site (already delivered, discarded, or retrying).",
SiteCallRelayOutcome.OperationFailed => ack.ErrorMessage,
// SiteUnreachable is never produced from a ParkedOperationActionAck —
// unreachable responses are built by UnreachableRetry/UnreachableDiscard
// before any ack is classified, so this arm is unreachable by construction.
// We deliberately return ack.ErrorMessage (rather than throwing) to keep
// AckErrorMessage total and side-effect-free: site-unreachable is classified
// as transient by the upstream relay path (which has already constructed the
// SiteUnreachable response and detail text via SiteUnreachableMessage), so a
// defensive fall-through here just surfaces whatever error text the ack
// carries and lets the caller schedule a retry. Throwing would turn a benign
// refactor invariant violation into a relay-path crash.
SiteCallRelayOutcome.SiteUnreachable => ack.ErrorMessage,
_ => throw new ArgumentOutOfRangeException(
nameof(outcome), outcome, "unknown SiteCallRelayOutcome"),
};
}
/// <summary>Shared "site unreachable" detail text for both relay directions.</summary>
private const string SiteUnreachableMessage =
"The owning site is unreachable; the action was not applied. Retry when the site is back online.";
private static RetrySiteCallResponse UnreachableRetry(string correlationId)
{
return new RetrySiteCallResponse(
correlationId,
SiteCallRelayOutcome.SiteUnreachable,
Success: false,
SiteReachable: false,
ErrorMessage: SiteUnreachableMessage);
}
private static DiscardSiteCallResponse UnreachableDiscard(string correlationId)
{
return new DiscardSiteCallResponse(
correlationId,
SiteCallRelayOutcome.SiteUnreachable,
Success: false,
SiteReachable: false,
ErrorMessage: SiteUnreachableMessage);
}
/// <summary>
/// Resolves an <see cref="ISiteCallAuditRepository"/> for one read message.
/// In test mode the injected instance is returned with a null scope; in
/// production a fresh DI scope is created and returned so the caller can
/// dispose it once the read completes — the same scope-per-message pattern
/// as <see cref="OnUpsertAsync"/>.
/// </summary>
private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository()
{
if (_injectedRepository is not null)
{
return (null, _injectedRepository);
}
var scope = _serviceProvider!.CreateScope();
return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
}
/// <summary>
/// A cached call counts as stuck when it is still non-terminal and was
/// created before <paramref name="stuckCutoff"/>. Non-terminal is keyed off
/// <see cref="SiteCall.TerminalAtUtc"/> being <c>null</c> — the
/// <c>SiteCalls</c> operational mirror stores <c>AuditStatus</c>-derived
/// status strings (<c>Attempted</c>/<c>Delivered</c>/<c>Parked</c>/...), not
/// the tracking-lifecycle <c>Pending</c>/<c>Retrying</c> names the spec's
/// KPI section uses, so there is no status string that means "buffered".
/// <c>TerminalAtUtc</c> is the entity's own active/terminal discriminator
/// and is consistent with the repository KPI counts and
/// <c>PurgeTerminalAsync</c>.
/// </summary>
private static bool IsStuck(SiteCall row, DateTime stuckCutoff)
{
return row.TerminalAtUtc is null && row.CreatedAtUtc < stuckCutoff;
}
private static SiteCallSummary ToSummary(SiteCall row, DateTime stuckCutoff)
{
return new SiteCallSummary(
TrackedOperationId: row.TrackedOperationId.Value,
SourceSite: row.SourceSite,
Channel: row.Channel,
Target: row.Target,
Status: row.Status,
RetryCount: row.RetryCount,
LastError: row.LastError,
HttpStatus: row.HttpStatus,
CreatedAtUtc: row.CreatedAtUtc,
UpdatedAtUtc: row.UpdatedAtUtc,
TerminalAtUtc: row.TerminalAtUtc,
IsStuck: IsStuck(row, stuckCutoff),
SourceNode: row.SourceNode);
}
private static SiteCallDetail ToDetail(SiteCall row)
{
return new SiteCallDetail(
TrackedOperationId: row.TrackedOperationId.Value,
SourceSite: row.SourceSite,
Channel: row.Channel,
Target: row.Target,
Status: row.Status,
RetryCount: row.RetryCount,
LastError: row.LastError,
HttpStatus: row.HttpStatus,
CreatedAtUtc: row.CreatedAtUtc,
UpdatedAtUtc: row.UpdatedAtUtc,
TerminalAtUtc: row.TerminalAtUtc,
IngestedAtUtc: row.IngestedAtUtc,
SourceNode: row.SourceNode);
}
/// <summary>
/// Treats an empty/whitespace filter string as "no constraint" — the
/// repository's <see cref="SiteCallQueryFilter"/> interprets <c>null</c> as
/// a no-op predicate, so a blank UI filter must collapse to <c>null</c>.
/// </summary>
private static string? NullIfBlank(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>Self-tick triggering a reconciliation pass across all sites (Piece A).</summary>
internal sealed class ReconciliationTick
{
public static readonly ReconciliationTick Instance = new();
private ReconciliationTick() { }
}
/// <summary>Self-tick triggering a terminal-row purge pass (Piece B).</summary>
internal sealed class PurgeTick
{
public static readonly PurgeTick Instance = new();
private PurgeTick() { }
}
}
/// <summary>
/// Registers the central→site command transport (the <c>CentralCommunicationActor</c>)
/// with the <see cref="SiteCallAuditActor"/> so it can relay Retry/Discard
/// actions on parked cached calls to their owning sites. Sent by the Host after
/// both actors exist. Lives here (not in Commons) because it carries an
/// <see cref="IActorRef"/> and <c>ZB.MOM.WW.ScadaBridge.Commons</c> has no Akka reference —
/// the same rationale as <c>RegisterAuditIngest</c>.
/// </summary>
public sealed record RegisterCentralCommunication(IActorRef CentralCommunication);