refactor(auditlog+sitecallaudit): delete the _injectedRepository test ctors — register row 61

AuditLogIngestActor and SiteCallAuditActor each exposed constructors taking a scoped
repository and caching it on a field for the actor's lifetime. Safe only because no
production call site picked those overloads — i.e. one call site away from re-introducing
the row-45 captive-dependency bug — which is why CaptiveDependencyGuard had to allowlist
both fields.

Preferred fix applied: the test fixtures fold onto the IServiceProvider overload with a
stub provider, and the constructors are gone.

Product deletions:
  - AuditLogIngestActor: the (IAuditLogRepository, ILogger) ctor, the _injectedRepository
    field and its branch in OnIngestAsync, and OnCachedTelemetryWithoutDualWriteAsync (the
    "this ctor cannot dual-write" fallback handler that only that ctor installed).
    _serviceProvider is now non-nullable and the null-forgiving operators are gone.
  - SiteCallAuditActor: both test ctors (repo-only, and repo + reconciliation
    collaborators), the _injectedRepository field and its four branches, and the private
    SerializedRepository class. _backgroundTimersEnabled went with them: only the deleted
    repo-only ctor ever set it false, so it was a constant and its two guards are removed.
    The reconciliation tick is still gated — on the collaborators being resolvable, which
    is how production reaches the same state.

Test fixtures (12 call sites): two small TestSupport helpers build the stub providers.
SiteCallAuditActor's production wiring resolves a FRESH repository per scope, so the
serializing decorator existed only to protect fixtures that share one DbContext-backed
instance across every scope — it moves into the test project as
SerializingSiteCallAuditRepository, which is where the hazard is actually created.
Fixtures that relied on it keep it. The relay test's ICentralAuditWriter now arrives via
the provider, exactly as production resolves it.

Behaviour is preserved on the paths under test: the deleted audit ctor passed
redactor:null / failureCounter:null, and a provider registering neither resolves both to
null, so the SafeDefaultAuditRedactor fallback and counter-free logging are unchanged. No
fixture on the folded sites sends IngestCachedTelemetryCommand, so swapping to the real
dual-write handler is inert. The SiteCallAudit purge timer now arms in fixtures that
previously had timers off, but every one of them uses default purge settings whose first
tick is 5 minutes out — nothing fires inside a test.

One reconciliation test was renamed and rewritten rather than deleted:
TestCtor_RepositoryOnly_DoesNotStartReconciliationTick became
NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick — the same
guarantee, expressed the way production actually reaches it.

Guard: the allowlist is now EMPTY. The staleness self-test (every entry must still match a
flagged field) is replaced by two stronger pins — Allowlist_IsEmpty asserts nothing is
exempt at all, and FormerlyAllowlistedActors_NowScanClean scans both actors with the
allowlist DISABLED, so the empty allowlist is evidence the fields are gone rather than
evidence the scan stopped looking.

Negative control: re-adding an ISiteCallAuditRepository field to SiteCallAuditActor fails
both FormerlyAllowlistedActors_NowScanClean and the real
Central_NoActorHoldsCaptiveScopedState scan.

Verification: solution builds clean. AuditLog.Tests 3/3 (383), SiteCallAudit.Tests 3/3
(66), Host.Tests 2/2 (505), IntegrationTests 2/2 (115), Communication.Tests 2/2 (717).
This commit is contained in:
Joseph Doherty
2026-08-15 17:49:39 -04:00
parent 7a7d345b89
commit 92fe5acca7
15 changed files with 474 additions and 452 deletions
@@ -42,13 +42,14 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// placeholder.
/// </para>
/// <para>
/// Two constructors exist for a deliberate reason: the test ctor injects a
/// concrete <see cref="IAuditLogRepository"/> against a per-test MSSQL fixture
/// (the only way to verify the ingest-timestamp stamp + duplicate-key
/// idempotency end to end), while the production host wiring registers the
/// actor as a cluster singleton and must therefore resolve the repository —
/// which is a scoped EF Core service — from a fresh DI scope per message.
/// Mirroring the Notification Outbox actor's pattern.
/// There is exactly ONE constructor, taking an <see cref="IServiceProvider"/>:
/// the actor is a cluster singleton and the repository is a scoped EF Core
/// service, so it must be resolved from a fresh DI scope per message and never
/// held on a field. Mirroring the Notification Outbox actor's pattern. Tests
/// that need a concrete repository (e.g. the MSSQL-backed fixture verifying the
/// ingest-timestamp stamp and duplicate-key idempotency) supply a stub provider
/// that hands one out, rather than a second constructor that would cache it —
/// see register row 61.
/// </para>
/// </remarks>
public class AuditLogIngestActor : ReceiveActor
@@ -100,41 +101,25 @@ public class AuditLogIngestActor : ReceiveActor
/// </remarks>
internal static readonly TimeSpan IngestFallbackBudget = TimeSpan.FromSeconds(5);
private readonly IServiceProvider? _serviceProvider;
private readonly IAuditLogRepository? _injectedRepository;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AuditLogIngestActor> _logger;
/// <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.
/// </summary>
/// <param name="repository">Audit log repository instance shared across all messages.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
public AuditLogIngestActor(
IAuditLogRepository repository,
ILogger<AuditLogIngestActor> logger)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository;
_logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
// The single-repository test ctor cannot service the dual-write —
// it has no SiteCalls repo and no DbContext. The handler still
// registers (so callers don't dead-letter) but replies empty so the
// test surface stays explicit about what this ctor supports.
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryWithoutDualWriteAsync);
}
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from
/// The only constructor — resolves <see cref="IAuditLogRepository"/> 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.
///
/// <para>
/// Register row 61: a second, repository-taking "test-mode" constructor used
/// to cache a scoped repository on a field for the actor's whole lifetime.
/// Nothing in production selected it, but it left the captive-dependency bug
/// one call site away and had to be allowlisted in the Host tests'
/// <c>CaptiveDependencyGuard</c>. It is gone; test fixtures build a stub
/// <see cref="IServiceProvider"/> instead, which keeps the actor's own
/// contract "resolve per scope, hold nothing".
/// </para>
/// </summary>
/// <param name="serviceProvider">Root service provider used to open a fresh scope per message.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
@@ -169,23 +154,16 @@ public class AuditLogIngestActor : ReceiveActor
var accepted = new List<Guid>(cmd.Events.Count);
// Resolve the repository for the whole batch — one DbContext per
// message, mirroring NotificationOutboxActor. The injected-repository
// mode (test ctor) skips the scope entirely.
// message, mirroring NotificationOutboxActor.
// The IAuditRedactor is also resolved from the per-message scope when
// one is available so the row is truncated + redacted before
// InsertIfNotExistsAsync. The single-repository test ctor has no
// service provider — it falls through with no redactor, which preserves
// the small-payload assumptions baked into the existing fixtures.
// InsertIfNotExistsAsync. A composition root that registers no redactor
// (test fixtures) falls back to the SafeDefault inside
// IngestWithRepositoryAsync rather than to pass-through.
// Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null)
{
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
else
{
//
// Guard scope-creation + repository resolution in a
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
// DbContext-factory fault (pooled-context init, SQL-connection
@@ -199,7 +177,7 @@ public class AuditLogIngestActor : ReceiveActor
// site keeps its rows Pending and retries on the next drain.
try
{
await using var scope = _serviceProvider!.CreateAsyncScope();
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// Central health counter is best-effort —
@@ -215,14 +193,13 @@ public class AuditLogIngestActor : ReceiveActor
// (or while) processing the batch. Surface a sustained fault on
// the dashboard if the counter is registered, but never let the
// throw escape the handler and restart the singleton.
try { _serviceProvider!.GetService<ICentralAuditWriteFailureCounter>()?.Increment(); }
try { _serviceProvider.GetService<ICentralAuditWriteFailureCounter>()?.Increment(); }
catch { /* counter must never throw — defence in depth */ }
_logger.LogError(
ex,
"Audit event batch ingest failed before/while resolving the repository scope; replying with {Accepted} accepted row(s). The site keeps unaccepted rows Pending and retries on the next drain.",
accepted.Count);
}
}
replyTo.Tell(new IngestAuditEventsReply(accepted));
}
@@ -357,7 +334,7 @@ public class AuditLogIngestActor : ReceiveActor
try
{
await using var scope = _serviceProvider!.CreateAsyncScope();
await using var scope = _serviceProvider.CreateAsyncScope();
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -544,18 +521,4 @@ public class AuditLogIngestActor : ReceiveActor
return true;
}
/// <summary>
/// Fallback handler installed on the single-repository test ctor — that
/// ctor has no DbContext and no <see cref="ISiteCallAuditRepository"/>, so
/// it cannot service the dual-write. Logs a warning and replies with an
/// empty ack so callers fall through to their retry path.
/// </summary>
private Task OnCachedTelemetryWithoutDualWriteAsync(IngestCachedTelemetryCommand cmd)
{
_logger.LogWarning(
"AuditLogIngestActor received IngestCachedTelemetryCommand on the single-repository ctor; dual-write requires the IServiceProvider ctor. Replying with empty ack ({Count} entries).",
cmd.Entries.Count);
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
return Task.CompletedTask;
}
}
@@ -87,8 +87,7 @@ public class SiteCallAuditActor : ReceiveActor
/// </summary>
private const int MaxReconciliationPagesPerTick = 50;
private readonly IServiceProvider? _serviceProvider;
private readonly ISiteCallAuditRepository? _injectedRepository;
private readonly IServiceProvider _serviceProvider;
private readonly SiteCallAuditOptions _options;
private readonly ILogger<SiteCallAuditActor> _logger;
@@ -108,28 +107,17 @@ public class SiteCallAuditActor : ReceiveActor
/// (<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"/>).
/// singletons registered by <c>AddAuditLogCentralReconciliationClient</c>).
/// They are <c>null</c> when the composition root did not register them (a
/// minimal host, or a test fixture that deliberately omits them) — 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
/// always armed (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
@@ -195,98 +183,23 @@ public class SiteCallAuditActor : ReceiveActor
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 = new SerializedRepository(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 = new SerializedRepository(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"/>
/// The only 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.
///
/// <para>
/// Register row 61: two further "test-mode" constructors used to take a
/// concrete repository and cache it (wrapped in a serializing decorator) on
/// a field for the actor's whole lifetime. Nothing in production selected
/// them, but they left the captive-dependency bug one call site away and had
/// to be allowlisted in the Host tests' <c>CaptiveDependencyGuard</c>. They
/// are gone; test fixtures build a stub <see cref="IServiceProvider"/>
/// instead — and own the serializing decorator themselves, since it exists
/// only to compensate for a fixture handing the SAME repository instance to
/// every scope. Production never needed it: each scope gets its own.
/// </para>
/// </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>
@@ -321,12 +234,6 @@ public class SiteCallAuditActor : ReceiveActor
_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();
}
@@ -462,11 +369,6 @@ public class SiteCallAuditActor : ReceiveActor
/// </summary>
private void StartReconciliationTimer()
{
if (!_backgroundTimersEnabled)
{
return;
}
if (_pullClient is null || _siteEnumerator is null)
{
_logger.LogWarning(
@@ -488,23 +390,16 @@ public class SiteCallAuditActor : ReceiveActor
/// <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
/// repository — never the reconciliation collaborators — so it is armed
/// unconditionally, NOT gated 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.
/// because the self-heal puller is absent.
/// </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
@@ -532,20 +427,11 @@ public class SiteCallAuditActor : ReceiveActor
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>();
}
// Scope-per-message mirrors AuditLogIngestActor — the EF repository is
// a scoped service, so it is resolved fresh here and never held.
IServiceScope? scope = _serviceProvider.CreateScope();
ISiteCallAuditRepository repository =
scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
try
{
@@ -624,15 +510,8 @@ public class SiteCallAuditActor : ReceiveActor
// 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();
// is retained for the synchronous message-handler paths.
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
await ReconcileSitesAsync(sites, client, repository).ConfigureAwait(false);
}
@@ -860,15 +739,8 @@ public class SiteCallAuditActor : ReceiveActor
// 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();
// synchronous Dispose(). Mirrors SiteAuditReconciliationActor.
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
await PurgeWithRepositoryAsync(repository, threshold).ConfigureAwait(false);
}
@@ -1454,19 +1326,13 @@ public class SiteCallAuditActor : ReceiveActor
/// <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"/>.
/// 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();
var scope = _serviceProvider.CreateScope();
return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
}
@@ -1565,91 +1431,6 @@ public class SiteCallAuditActor : ReceiveActor
private PurgeComplete() { }
}
/// <summary>
/// Serializing wrapper applied to a repository handed in through the
/// test constructors. One call at a time, in arrival order.
/// </summary>
/// <remarks>
/// <para>
/// In production every consumer of this actor's repository gets its OWN DI
/// scope — one per message, one per background pass — so the off-mailbox
/// reconciliation and purge passes can safely run alongside mailbox handlers:
/// separate scopes mean separate <c>DbContext</c>s. The injected-repository
/// constructors break that assumption: ONE instance (typically wrapping one
/// <c>DbContext</c>) is shared by the mailbox handlers AND the background
/// passes, and <c>DbContext</c> forbids concurrent operations — "A second
/// operation was started on this context instance" is a hard fault, and the
/// interleaving is nondeterministic, so it surfaces as a flaky test rather
/// than a reliable one.
/// </para>
/// <para>
/// Serializing at the CALL, not around the whole pass, is deliberate: it
/// removes the concurrency hazard while preserving the property the
/// off-mailbox passes exist for — a long drain (blocked in a network pull,
/// holding no repository call) still lets ingest, query and KPI messages be
/// answered. Suspending the mailbox for the duration of a pass would have
/// been simpler and would have invalidated exactly those regression tests.
/// Production is untouched: it never constructs this type.
/// </para>
/// </remarks>
private sealed class SerializedRepository : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly SemaphoreSlim _gate = new(1, 1);
public SerializedRepository(ISiteCallAuditRepository inner) => _inner = inner;
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
private async Task RunAsync(Func<Task> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task<T> RunAsync<T>(Func<Task<T>> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
return await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}
}
/// <summary>
@@ -51,10 +51,15 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
status: AuditStatus.Delivered,
sourceSiteId: siteId);
private IActorRef CreateActor(IAuditLogRepository repository) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repository,
private IActorRef CreateActor(IAuditLogRepository repository)
{
// Register row 61: the actor's repository-taking test ctor is gone; the fixture
// supplies a stub provider that hands this repository out of every scope.
var provider = TestSupport.StubAuditRepositoryProvider.Build(repository);
return Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
provider,
NullLogger<AuditLogIngestActor>.Instance)));
}
[SkippableFact]
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
@@ -130,10 +130,15 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
private IActorRef CreateIngestActor(IAuditLogRepository repo)
{
// Register row 61: the actor's repository-taking test ctor is gone; the fixture
// supplies a stub provider that hands this repository out of every scope.
var provider = TestSupport.StubAuditRepositoryProvider.Build(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
provider,
NullLogger<AuditLogIngestActor>.Instance)));
}
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
@@ -127,10 +127,15 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
private IActorRef CreateIngestActor(IAuditLogRepository repo)
{
// Register row 61: the actor's repository-taking test ctor is gone; the fixture
// supplies a stub provider that hands this repository out of every scope.
var provider = TestSupport.StubAuditRepositoryProvider.Build(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
provider,
NullLogger<AuditLogIngestActor>.Instance)));
}
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
@@ -110,10 +110,15 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
private IActorRef CreateIngestActor(IAuditLogRepository repo)
{
// Register row 61: the actor's repository-taking test ctor is gone; the fixture
// supplies a stub provider that hands this repository out of every scope.
var provider = TestSupport.StubAuditRepositoryProvider.Build(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
provider,
NullLogger<AuditLogIngestActor>.Instance)));
}
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
/// <summary>
/// Deferred-work register row 61 — the replacement for <c>AuditLogIngestActor</c>'s deleted
/// repository-taking "test-mode" constructor.
///
/// <para>
/// That constructor cached a scoped repository on an actor field for the actor's whole
/// lifetime. Nothing in production selected it, but the shape was one call site away from
/// re-introducing a captive-dependency bug, so it had to be allowlisted in the Host tests'
/// <c>CaptiveDependencyGuard</c>. The actor now has a single <see cref="IServiceProvider"/>
/// constructor; a fixture that wants a concrete repository builds one of these instead.
/// </para>
///
/// <para>
/// Behaviour is unchanged for the ingest path: the old ctor passed <c>redactor: null</c> and
/// <c>failureCounter: null</c>, and a provider with neither registered resolves both to null
/// too — so the actor still falls back to <c>SafeDefaultAuditRedactor</c> and still logs
/// without a health counter. The one deliberate difference is that
/// <c>IngestCachedTelemetryCommand</c> now takes the real dual-write handler rather than the
/// deleted "no dual write" stub; no fixture built on this helper sends that message.
/// </para>
/// </summary>
internal static class StubAuditRepositoryProvider
{
/// <summary>
/// Builds a provider whose every scope yields the same audit-log repository instance.
/// </summary>
/// <param name="repository">Repository instance shared by every scope.</param>
public static ServiceProvider Build(IAuditLogRepository repository)
{
ArgumentNullException.ThrowIfNull(repository);
return new ServiceCollection()
.AddScoped(_ => repository)
.BuildServiceProvider();
}
}
@@ -72,28 +72,25 @@ internal static class CaptiveDependencyGuard
/// </summary>
/// <remarks>
/// <para>
/// Both entries below are the same shape: a nullable repository field assigned ONLY by an
/// explicitly test-mode constructor, so the production wiring
/// (<c>AkkaHostedService</c> constructs both actors through their <see cref="IServiceProvider"/>
/// overload) leaves the field null and every handler takes the create-a-scope branch. The
/// test fixtures that do use those constructors are MSSQL-backed TestKit fixtures that
/// drive the actor one message at a time, and <c>SiteCallAuditActor</c> additionally wraps
/// the injected instance in its own <c>SerializedRepository</c> — i.e. the test path
/// already carries a serialization guard for precisely the concurrency hazard row 45 hit.
/// EMPTY, and pinned empty by <c>CaptiveDependencyGuardTests.Allowlist_IsEmpty</c>.
/// </para>
/// <para>
/// They are allowlisted rather than removed because deleting a test-mode constructor is a
/// larger change than this row justifies, and rather than left unflagged because the shape
/// is genuinely one constructor call away from being row 45 again: if a production call
/// site ever switched to the repository overload, the actor would hold a scoped EF-backed
/// repository for its whole lifetime. That residual risk is recorded, not dismissed.
/// It previously held two entries — <c>AuditLogIngestActor._injectedRepository</c> and
/// <c>SiteCallAuditActor._injectedRepository</c> — nullable repository fields assigned only
/// by explicitly test-mode constructors. They were safe solely because no production call
/// site picked those overloads, which left the row-45 captive-dependency bug one
/// constructor call away. Register row 61 deleted the constructors instead: both actors now
/// have a single <see cref="IServiceProvider"/> constructor, the fields are gone, and the
/// test fixtures build stub providers that hand a repository out of a scope. With the shape
/// removed there is nothing left to exempt.
/// </para>
/// <para>
/// Keep it empty. A new entry means someone reintroduced a scoped service cached on an
/// actor field; prefer fixing the actor.
/// </para>
/// </remarks>
internal static readonly IReadOnlySet<string> Allowlist = new HashSet<string>(StringComparer.Ordinal)
{
"ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogIngestActor._injectedRepository",
"ZB.MOM.WW.ScadaBridge.SiteCallAudit.SiteCallAuditActor._injectedRepository",
};
internal static readonly IReadOnlySet<string> Allowlist =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Scans the given actor types for instance fields holding captive scope state.
@@ -166,38 +166,65 @@ public class CaptiveDependencyGuardSelfTests
}
/// <summary>
/// The allowlist is an escape hatch, so it is itself pinned: every entry must name a
/// field that still exists and would still be flagged. An entry that has stopped matching
/// is either a stale exemption or — worse — a typo silently covering nothing.
/// Register row 61: the allowlist is now EMPTY and must stay that way.
///
/// <para>
/// This replaces the former staleness self-test, which checked that each entry still named
/// a field the scanner would flag. Its two entries — <c>AuditLogIngestActor</c> and
/// <c>SiteCallAuditActor</c>'s <c>_injectedRepository</c> — covered repository fields
/// assigned only by test-mode constructors; those constructors have been deleted and the
/// fixtures fold onto the <c>IServiceProvider</c> overload, so there is no longer a field
/// to exempt. An empty allowlist is a strictly stronger statement than a correct one:
/// nothing is exempt, so nothing can rot.
/// </para>
/// </summary>
[Fact]
public void Allowlist_EntriesAllStillMatchARealFlaggedField()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
// Scan with the allowlist disabled against a scoped set wide enough to include the
// repositories the allowlisted fields hold.
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
var flagged = unfiltered
.Select(v => $"{v.ActorType.FullName}.{v.FieldName}")
.ToHashSet(StringComparer.Ordinal);
foreach (var entry in CaptiveDependencyGuard.Allowlist)
public void Allowlist_IsEmpty_NoActorExemptFromTheCaptiveDependencyScan()
{
Assert.True(
flagged.Contains(entry),
$"Allowlist entry '{entry}' no longer matches a flagged field. Remove it — a stale " +
"exemption is indistinguishable from an exemption that never worked.");
}
CaptiveDependencyGuard.Allowlist.Count == 0,
"The captive-dependency allowlist must stay empty (register row 61). Entries present: " +
string.Join(", ", CaptiveDependencyGuard.Allowlist) +
". An entry means an actor caches a scoped service on a field — fix the actor " +
"(fold test fixtures onto the IServiceProvider overload) rather than exempting it.");
}
/// <summary>
/// A minimal descriptor set naming the two repository types the allowlisted fields hold,
/// so <see cref="Allowlist_EntriesAllStillMatchARealFlaggedField"/> does not need to boot a
/// host. Both are registered <c>Scoped</c> by <c>AddConfigurationDatabase</c>; the
/// role-scan tests below verify that against the real container.
/// Register row 61 (the other half of the pin): with the allowlist empty, the two actors
/// that used to need exemptions must now scan CLEAN against the scoped repository types
/// they resolve. This is what makes the empty allowlist meaningful — it proves the entries
/// were removed because the fields are gone, not because the scan stopped looking.
/// </summary>
[Fact]
public void FormerlyAllowlistedActors_NowScanClean()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
// Allowlist disabled, so a surviving _injectedRepository-shaped field would still show.
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
var offenders = unfiltered
.Where(v => v.ActorType.Name is "AuditLogIngestActor" or "SiteCallAuditActor")
.Select(v => v.ToString())
.ToList();
Assert.True(offenders.Count == 0,
"AuditLogIngestActor / SiteCallAuditActor must hold no scoped repository field " +
"(register row 61). Found: " + string.Join(" | ", offenders));
// Sanity: the scan really did examine those two actors, so the clean result above is
// evidence rather than an empty walk.
Assert.Contains(actorTypes, t => t.Name == "AuditLogIngestActor");
Assert.Contains(actorTypes, t => t.Name == "SiteCallAuditActor");
}
/// <summary>
/// A minimal descriptor set naming the two repository types the deleted test-mode
/// constructors used to cache, so <see cref="FormerlyAllowlistedActors_NowScanClean"/> does
/// not need to boot a host. Both are registered <c>Scoped</c> by
/// <c>AddConfigurationDatabase</c>; the role-scan tests below verify that against the real
/// container.
/// </summary>
/// <returns>The probe descriptors.</returns>
private static IServiceCollection BuildCentralishScopedProbe()
@@ -149,11 +149,16 @@ public class SiteAuditPushFlowTests : TestKit
public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded()
{
// ── Central side ──────────────────────────────────────────────────
// Real AuditLogIngestActor over an in-memory repository (test-mode ctor).
// Real AuditLogIngestActor over an in-memory repository. Register row 61: the actor's
// repository-taking test ctor is gone, so the repository is handed out of a stub
// provider's scope instead of being cached on an actor field.
var centralRepo = new InMemoryAuditLogRepository();
var ingestProvider = new ServiceCollection()
.AddScoped<IAuditLogRepository>(_ => centralRepo)
.BuildServiceProvider();
var ingestActor = Sys.ActorOf(Props.Create(() =>
new ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogIngestActor(
centralRepo,
(IServiceProvider)ingestProvider,
NullLogger<ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogIngestActor>.Instance)));
// Real CentralCommunicationActor. It is NOT on the audit path any more (the
@@ -73,11 +73,19 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
private IActorRef CreateActor(
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repository,
NullLogger<SiteCallAuditActor>.Instance,
options)));
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null)
{
// Register row 61: the actor's repository-taking test ctor is gone. This MSSQL-backed
// fixture supplies a stub provider that yields the same repository from every scope,
// serialized (one DbContext cannot service concurrent operations) — see
// StubRepositoryProvider. No reconciliation collaborators are registered, so the
// reconciliation tick stays disarmed exactly as it did under the old ctor.
var provider = TestSupport.StubRepositoryProvider.Build(repository);
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options ?? new SiteCallAuditOptions(),
NullLogger<SiteCallAuditActor>.Instance)));
}
[SkippableFact]
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
@@ -102,13 +102,17 @@ public class SiteCallAuditPurgeTests : TestKit
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
}
private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
new EmptyEnumerator(),
new NoOpPullClient(),
NullLogger<SiteCallAuditActor>.Instance,
options)));
private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options)
{
// Register row 61: the actor's repository-taking test ctor is gone. The fixture now
// supplies a stub provider that hands the recording repo out of every scope.
var provider = TestSupport.StubRepositoryProvider.Build(
repo, new EmptyEnumerator(), new NoOpPullClient());
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
}
// ---------------------------------------------------------------------
// 1. PurgeTick_CallsPurgeTerminal_WithRetentionThreshold
@@ -238,13 +238,18 @@ public class SiteCallAuditReconciliationTests : TestKit
ISiteEnumerator sites,
IPullSiteCallsClient client,
ISiteCallAuditRepository repo,
SiteCallAuditOptions options) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
sites,
client,
NullLogger<SiteCallAuditActor>.Instance,
options)));
SiteCallAuditOptions options)
{
// Register row 61: the actor's repository+collaborators test ctor is gone. The
// collaborators now reach it the way production supplies them — resolved from the
// provider — and the repository comes out of a scope, serialized because this fixture
// deliberately shares one instance across every scope.
var provider = TestSupport.StubRepositoryProvider.Build(repo, sites, client);
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
}
// ---------------------------------------------------------------------
// 1. AbsentRow_PulledFromSite_IsUpserted
@@ -348,28 +353,30 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 4. RepoOnly test ctor does NOT start the reconciliation tick
// 4. A composition root without the reconciliation collaborators does NOT
// start the reconciliation tick
// ---------------------------------------------------------------------
[Fact]
public void TestCtor_RepositoryOnly_DoesNotStartReconciliationTick()
public void NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick()
{
// The repo-only test ctor (used by the MSSQL-backed actor tests) injects
// no client/enumerator, so the tick must be gated OFF — otherwise those
// tests would fire phantom pulls. Build the actor via that ctor and
// confirm no pull ever happens. We can't observe a non-event directly,
// so we share a ScriptedPullClient with an isolated actor that DOES run
// the tick to bound the wait, then assert the repo-only actor's client
// (a separate instance) recorded nothing.
// Register row 61: this used to be phrased as "the repo-only test ctor gates the tick
// off". With that ctor deleted, the same guarantee is expressed the way production
// actually reaches it — a composition root that registers NEITHER IPullSiteCallsClient
// NOR ISiteEnumerator leaves the tick disarmed (GetService returns null for both), so
// the MSSQL-backed actor tests fire no phantom pulls. We can't observe a non-event
// directly, so we run an isolated actor that DOES tick to bound the wait, then assert
// this actor upserted nothing on its own.
var repo = new RecordingRepo();
var bareProvider = TestSupport.StubRepositoryProvider.Build(repo);
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
NullLogger<SiteCallAuditActor>.Instance,
FastTickOptions())));
bareProvider,
FastTickOptions(),
NullLogger<SiteCallAuditActor>.Instance)));
// Run a parallel actor with the full reconciliation ctor and a fast
// Run a parallel actor whose provider DOES carry the collaborators, with a fast
// tick; once IT has pulled we know enough wall-clock elapsed that the
// repo-only actor would have ticked too, had it been wired.
// collaborator-less actor would have ticked too, had it been wired.
var liveClient = new ScriptedPullClient();
var liveRepo = new RecordingRepo();
CreateActor(
@@ -383,8 +390,8 @@ public class SiteCallAuditReconciliationTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// The repo-only actor never reconciles: it has no client to pull with,
// so it upserts nothing on its own.
// The collaborator-less actor never reconciles: it has no client to pull
// with, so it upserts nothing on its own.
Assert.Equal(0, repo.UpsertCallCount);
}
@@ -64,10 +64,12 @@ public class SiteCallRelayTests : TestKit
private IActorRef CreateActor(IActorRef centralComm)
{
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
// Register row 61: stub provider in place of the deleted repository ctor.
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
new ThrowingRepository(),
NullLogger<SiteCallAuditActor>.Instance,
options)));
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RegisterCentralCommunication(centralComm));
return actor;
}
@@ -202,10 +204,11 @@ public class SiteCallRelayTests : TestKit
// No RegisterCentralCommunication — the actor has no transport to reach
// any site, so the only honest answer is "unreachable".
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
new ThrowingRepository(),
NullLogger<SiteCallAuditActor>.Instance,
options)));
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north"));
@@ -286,11 +289,15 @@ public class SiteCallRelayTests : TestKit
{
var writer = new RecordingCentralAuditWriter();
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
// The audit writer now reaches the actor through the provider (production resolves it
// the same way, from the root provider) rather than through a ctor parameter.
var provider = TestSupport.StubRepositoryProvider.Build(
new RowReturningRepository(row),
NullLogger<SiteCallAuditActor>.Instance,
auditWriter: (ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer);
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
(ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer)));
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RegisterCentralCommunication(centralComm));
return (actor, writer);
}
@@ -0,0 +1,162 @@
using Microsoft.Extensions.DependencyInjection;
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.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.TestSupport;
/// <summary>
/// Deferred-work register row 61 — the replacement for <c>SiteCallAuditActor</c>'s deleted
/// repository-taking "test-mode" constructors.
///
/// <para>
/// Those constructors cached a scoped repository on an actor field for the actor's whole
/// lifetime. Nothing in production selected them, but the shape was one call site away from
/// re-introducing a captive-dependency bug, so it had to be allowlisted in the Host tests'
/// <c>CaptiveDependencyGuard</c>. The actor now has a single <see cref="IServiceProvider"/>
/// constructor; a fixture that wants a concrete repository builds one of these instead.
/// </para>
///
/// <para>
/// The actor's contract is unchanged and unweakened — it still resolves per scope and holds
/// nothing. Handing the SAME instance out of every scope is now the FIXTURE's explicit
/// choice, which is also why the serializing decorator below lives here rather than in
/// product code: it exists solely to compensate for that choice.
/// </para>
/// </summary>
internal static class StubRepositoryProvider
{
/// <summary>
/// Builds a provider whose every scope yields the same repository instance, wrapped in
/// <see cref="SerializingSiteCallAuditRepository"/>, plus any collaborators supplied.
/// </summary>
/// <param name="repository">Repository instance shared by every scope.</param>
/// <param name="siteEnumerator">Optional site enumerator; omit to leave the reconciliation tick disarmed.</param>
/// <param name="pullClient">Optional pull client; omit to leave the reconciliation tick disarmed.</param>
/// <param name="auditWriter">Optional central direct-write audit sink for relay actions.</param>
/// <param name="serialize">
/// False registers the repository unwrapped — for fixtures asserting on call ordering or
/// identity where the decorator would get in the way.
/// </param>
public static ServiceProvider Build(
ISiteCallAuditRepository repository,
ISiteEnumerator? siteEnumerator = null,
IPullSiteCallsClient? pullClient = null,
ICentralAuditWriter? auditWriter = null,
bool serialize = true)
{
ArgumentNullException.ThrowIfNull(repository);
var shared = serialize ? new SerializingSiteCallAuditRepository(repository) : repository;
var services = new ServiceCollection();
services.AddScoped(_ => shared);
if (siteEnumerator is not null)
{
services.AddSingleton(siteEnumerator);
}
if (pullClient is not null)
{
services.AddSingleton(pullClient);
}
if (auditWriter is not null)
{
services.AddSingleton(auditWriter);
}
return services.BuildServiceProvider();
}
}
/// <summary>
/// Serializing wrapper for a repository instance shared across every DI scope in a test.
/// One call at a time, in arrival order.
/// </summary>
/// <remarks>
/// <para>
/// In production every consumer of <c>SiteCallAuditActor</c>'s repository gets its OWN DI
/// scope — one per message, one per background pass — so the off-mailbox reconciliation and
/// purge passes can safely run alongside mailbox handlers: separate scopes mean separate
/// <c>DbContext</c>s. A fixture that hands ONE instance (typically wrapping one
/// <c>DbContext</c>) to every scope breaks that assumption, and <c>DbContext</c> forbids
/// concurrent operations — "A second operation was started on this context instance" is a
/// hard fault, and the interleaving is nondeterministic, so it surfaces as a flaky test
/// rather than a reliable one.
/// </para>
/// <para>
/// Serializing at the CALL, not around the whole pass, is deliberate: it removes the
/// concurrency hazard while preserving the property the off-mailbox passes exist for — a long
/// drain (blocked in a network pull, holding no repository call) still lets ingest, query and
/// KPI messages be answered. Suspending the mailbox for the duration of a pass would have
/// been simpler and would have invalidated exactly those regression tests.
/// </para>
/// <para>
/// This type used to live inside the product actor, reachable only from its test
/// constructors. It moved here with register row 61: production never constructed it, and the
/// hazard it guards is created by the fixture, not by the actor.
/// </para>
/// </remarks>
internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly SemaphoreSlim _gate = new(1, 1);
public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner;
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
private async Task RunAsync(Func<Task> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task<T> RunAsync<T>(Func<Task<T>> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
return await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}