Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs
T
Joseph Doherty 5d075f1374 fix(central): review findings — no client-side audit truncation, insert-first upsert, QI-safe scripts, honest operator replies
Six adversarial-review findings in the central SQL/ingest layer.

F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each
string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16,
Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind
time and committed the mutilated row — silent, in an append-only store, with no
PayloadTruncated flag — while the per-row and reconciliation paths sent the same
value in full and let the server reject it with 2628. Bind at the value's own
length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's
derived column types and datetime2 precision). Design: reject everywhere,
truncate nowhere — matching today's per-row behaviour.

F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the
monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing
the first packet of one TrackedOperationId (the cached dual-write and the
reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and
the loser then skipped its INSERT or swallowed a 2627 — dropping its
Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to
`IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the
loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs
the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed
parameters so the intricate rank predicate exists in exactly one place (an
untyped DateTime would bind as `datetime` and round the freshness tiebreaker).

F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the
documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF;
once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too.
All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO`
(own batch, so it is in force when the next batch parses), and the migration
convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified
live: the pre-fix script fails 1934 without -I, the fixed one applies.

F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the
injected repository, so tests drove one DbContext from the pass and a mailbox
handler concurrently. Serialized at the CALL via a private SerializedRepository
wrapper applied only by the test constructors, rather than running the pass
on-mailbox: production keeps its PipeTo shape untouched, and the existing
"a blocked drain does not stall ingest/query/KPI" regression tests stay
meaningful (they would have been invalidated by suspending the mailbox).

F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget
expired, the per-row fallback reused the same expired token: N instant failures,
N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside
the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the
batch instead of once per row.

F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was
discarded, so an operator Retry/Discard of a notification the retention purge had
already deleted reported success (the pre-ExecuteUpdate code threw
DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the
operator one-shots answer "notification not found" and emit no audit row for the
action that did not happen, while the dispatcher logs a warning (its delivery
already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since
the write is out-of-band.

Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths +
boundary round-trip; concurrent first-write and already-created-by-another-writer
upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback,
a repository-concurrency detector for the SiteCallAudit passes, and vanished-row
operator-path tests. The F1/F2/F4 regressions were each confirmed failing against
the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit
66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
2026-08-14 23:46:28 -04:00

863 lines
38 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 Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
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.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Reconciliation-tick tests for <see cref="SiteCallAuditActor"/> (#22, Piece A).
/// These exercise the periodic per-site self-heal pull entirely in-memory —
/// fake <see cref="IPullSiteCallsClient"/> + <see cref="ISiteEnumerator"/> + a
/// recording <see cref="ISiteCallAuditRepository"/> — so they run in
/// milliseconds and do NOT depend on a live MSSQL fixture (unlike the
/// MSSQL-backed <see cref="SiteCallAuditActorTests"/>). The actor is built via
/// the internal test ctor that injects all three collaborators; the
/// repo-only test ctor used by the MSSQL tests passes no client/enumerator, so
/// the reconciliation tick is gated off there (see
/// <see cref="TestCtor_RepositoryOnly_DoesNotStartReconciliationTick"/>).
/// </summary>
public class SiteCallAuditReconciliationTests : TestKit
{
private static SiteCall NewRow(
TrackedOperationId id,
string sourceSite,
string status = "Submitted",
DateTime? updatedAtUtc = null)
{
var now = updatedAtUtc ?? DateTime.UtcNow;
return new SiteCall
{
TrackedOperationId = id,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = sourceSite,
SourceNode = null,
Status = status,
RetryCount = 0,
LastError = null,
HttpStatus = null,
CreatedAtUtc = now,
UpdatedAtUtc = now,
TerminalAtUtc = null,
IngestedAtUtc = now,
};
}
private static SiteCallAuditOptions FastTickOptions(int batchSize = 500) => new()
{
// 100 ms tick keeps each test under a second; AwaitAssert covers
// scheduler jitter so the tick has up to a few seconds to fire.
ReconciliationInterval = TimeSpan.FromMinutes(5),
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
ReconciliationBatchSize = batchSize,
};
/// <summary>In-memory enumerator returning a static list of sites.</summary>
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
/// <summary>
/// Scripted pull client — returns the next queued response for the site on
/// each call (looping the last entry once exhausted) and records every
/// invocation so tests can assert call counts + the <c>since</c> cursor.
/// </summary>
private sealed class ScriptedPullClient : IPullSiteCallsClient
{
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
private readonly Dictionary<string, Queue<PullSiteCallsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = new();
public ScriptedPullClient Script(string siteId, params PullSiteCallsResponse[] responses)
{
_scripted[siteId] = new Queue<PullSiteCallsResponse>(responses);
return this;
}
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{
_throwOnSite[siteId] = ex;
return this;
}
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
Calls.Add((siteId, sinceUtc, afterId, batchSize));
if (_throwOnSite.TryGetValue(siteId, out var ex))
{
throw ex;
}
if (_scripted.TryGetValue(siteId, out var queue) && queue.Count > 0)
{
return Task.FromResult(queue.Dequeue());
}
return Task.FromResult(
new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false));
}
}
/// <summary>
/// Keyset-honouring in-memory pull client (Task 16). Holds one static set of
/// rows and answers each pull as a real site would: returns the next
/// <c>batchSize</c> rows strictly greater than the composite
/// <c>(sinceUtc, afterId)</c> cursor under the deterministic
/// <c>(UpdatedAtUtc, TrackedOperationId ordinal)</c> ordering, with
/// <c>MoreAvailable=true</c> when unseen rows remain. This is the modern site
/// that DOES honour <c>after_id</c>, so a burst sharing one exact
/// <c>UpdatedAtUtc</c> drains fully via the id tiebreak — no single-timestamp
/// pin.
/// </summary>
private sealed class KeysetHonoringPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
public int CallCount { get; private set; }
public KeysetHonoringPullClient(IEnumerable<SiteCall> rows) =>
_rows = rows
.OrderBy(r => r.UpdatedAtUtc)
.ThenBy(r => r.TrackedOperationId.ToString(), StringComparer.Ordinal)
.ToList();
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
CallCount++;
var greater = _rows
.Where(r =>
r.UpdatedAtUtc > sinceUtc ||
(r.UpdatedAtUtc == sinceUtc &&
string.CompareOrdinal(r.TrackedOperationId.ToString(), afterId) > 0))
.ToList();
var page = greater.Take(batchSize).ToList();
var more = greater.Count > page.Count;
return Task.FromResult(
new PullSiteCallsResponse(page, MoreAvailable: more));
}
}
/// <summary>
/// Legacy pull client (Task 16) that IGNORES <c>afterId</c> and always
/// returns the same saturated page (<c>MoreAvailable=true</c>) — models a
/// pre-keyset site the central actor cannot drain past. The composite cursor
/// cannot advance, so the actor must latch and publish
/// <see cref="SiteCallReconciliationPinnedChanged"/> instead of spinning.
/// </summary>
private sealed class LegacyIgnoresAfterIdPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
public int CallCount { get; private set; }
public LegacyIgnoresAfterIdPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
CallCount++;
return Task.FromResult(new PullSiteCallsResponse(_rows, MoreAvailable: true));
}
}
/// <summary>
/// Pull client that ALWAYS returns the same saturated response
/// (<c>MoreAvailable=true</c>) regardless of the <c>since</c> cursor —
/// simulates the SiteCallAudit-009 single-timestamp no-progress pin: a backlog
/// larger than the batch size all sharing one exact <c>UpdatedAtUtc</c>, so
/// the inclusive max-timestamp cursor never advances. Records every call so
/// the test can assert the within-tick drain is BOUNDED (the actor must not
/// spin the dispatcher forever on this pathological input).
/// </summary>
private sealed class SaturatedPinPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
public int CallCount { get; private set; }
public SaturatedPinPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
CallCount++;
return Task.FromResult(new PullSiteCallsResponse(_rows, MoreAvailable: true));
}
}
/// <summary>
/// Recording repository that captures every <see cref="UpsertAsync"/> call
/// (keyed by id, last-write-wins on the captured row). The reconciliation
/// tick only ever calls <see cref="UpsertAsync"/>; the read/KPI members are
/// inert stubs.
/// </summary>
private sealed class RecordingRepo : ISiteCallAuditRepository
{
public Dictionary<TrackedOperationId, SiteCall> Upserted { get; } = new();
public int UpsertCallCount { get; private set; }
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
UpsertCallCount++;
Upserted[siteCall.TrackedOperationId] = siteCall;
return Task.CompletedTask;
}
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult(Upserted.TryGetValue(id, out var row) ? row : null);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
Task.FromResult(0);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
}
private IActorRef CreateActor(
ISiteEnumerator sites,
IPullSiteCallsClient client,
ISiteCallAuditRepository repo,
SiteCallAuditOptions options) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
sites,
client,
NullLogger<SiteCallAuditActor>.Instance,
options)));
// ---------------------------------------------------------------------
// 1. AbsentRow_PulledFromSite_IsUpserted
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_AbsentRow_IsUpsertedFromSitePull()
{
var siteId = "siteA";
var id = TrackedOperationId.New();
var row = NewRow(id, sourceSite: siteId, status: "Parked");
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(new[] { row }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(
() =>
{
Assert.True(repo.Upserted.ContainsKey(id),
"reconciliation tick should upsert the row present at the site but absent centrally");
Assert.Equal("Parked", repo.Upserted[id].Status);
Assert.Equal(siteId, repo.Upserted[id].SourceSite);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 2. Cursor_Advances_ToMaxUpdatedAtUtc_NoRePullOfOldRows
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_SecondTick_AdvancesCursorPastAlreadyPulledRows()
{
var siteId = "siteA";
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
var r1 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t1);
var r2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t2);
var r3 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t3);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
// First pull returns three rows (max UpdatedAtUtc = t3); subsequent
// pulls return empty. The second pull's `since` must be t3, proving the
// cursor advanced and old rows are not re-pulled from the start.
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(new[] { r1, r2, r3 }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(
() => Assert.True(client.Calls.Count >= 2,
$"need at least 2 pulls to assert cursor advancement, got {client.Calls.Count}"),
duration: TimeSpan.FromSeconds(5),
interval: TimeSpan.FromMilliseconds(50));
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t3, client.Calls[1].SinceUtc);
// The batch size flows through from options.
Assert.Equal(500, client.Calls[0].BatchSize);
}
// ---------------------------------------------------------------------
// 3. OneSiteThrows_OtherSitesStillProcessed (failure isolation)
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_OneSiteThrows_OtherSitesStillReconciled()
{
var siteB = "siteB";
var bId = TrackedOperationId.New();
var bRow = NewRow(bId, sourceSite: siteB, status: "Delivered");
var sites = new StaticEnumerator(
new SiteEntry("siteA", "http://siteA:8083"),
new SiteEntry(siteB, "http://siteB:8083"));
var client = new ScriptedPullClient()
.ThrowFor("siteA", new InvalidOperationException("simulated transport failure"))
.Script(siteB, new PullSiteCallsResponse(new[] { bRow }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(
() =>
{
// siteA was attempted (and threw) yet siteB's row still landed —
// one offline site must not sink the rest of the tick.
Assert.Contains(client.Calls, c => c.SiteId == "siteA");
Assert.True(repo.Upserted.ContainsKey(bId),
"siteB must be reconciled even though siteA threw");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 4. RepoOnly test ctor does NOT start the reconciliation tick
// ---------------------------------------------------------------------
[Fact]
public void TestCtor_RepositoryOnly_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.
var repo = new RecordingRepo();
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
NullLogger<SiteCallAuditActor>.Instance,
FastTickOptions())));
// Run a parallel actor with the full reconciliation ctor and 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.
var liveClient = new ScriptedPullClient();
var liveRepo = new RecordingRepo();
CreateActor(
new StaticEnumerator(new SiteEntry("siteX", "http://siteX:8083")),
liveClient,
liveRepo,
FastTickOptions());
AwaitAssert(
() => Assert.True(liveClient.Calls.Count >= 1),
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.
Assert.Equal(0, repo.UpsertCallCount);
}
// ---------------------------------------------------------------------
// 5. SiteCallAudit-009: MoreAvailable drives a within-tick continuation
// drain — a multi-page backlog whose timestamps advance is fully drained
// in ONE tick rather than one page per tick.
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_MoreAvailable_DrainsMultiplePagesWithinOneTick()
{
var siteId = "siteA";
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
var p1a = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t1);
var p1b = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t2);
var p2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t3);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
// Page 1 saturates (MoreAvailable: true) → the actor continues pulling
// within the SAME tick; page 2 is the final page (MoreAvailable: false).
// The continuation pull's `since` must be t2 (page-1 max), proving the
// cursor advanced page-to-page inside one tick rather than across ticks.
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(new[] { p1a, p1b }, MoreAvailable: true),
new PullSiteCallsResponse(new[] { p2 }, MoreAvailable: false));
var repo = new RecordingRepo();
// Slow tick so the multi-page drain CANNOT be the natural tick cadence —
// it must be the within-tick continuation loop. Long enough that only the
// first tick fires in the assert window.
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
AwaitAssert(
() =>
{
// All three rows reconciled — including the page-2 row that only a
// within-tick continuation pull could have fetched.
Assert.True(repo.Upserted.ContainsKey(p1a.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(p1b.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(p2.TrackedOperationId),
"the page-2 row must be reconciled within the same tick via the MoreAvailable continuation drain");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Exactly two pulls happened (page 1 + the continuation page 2) and the
// second pull's `since` cursor advanced to the page-1 max (t2).
Assert.True(client.Calls.Count >= 2, $"expected >= 2 pulls within the tick, got {client.Calls.Count}");
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t2, client.Calls[1].SinceUtc);
}
// ---------------------------------------------------------------------
// 6. SiteCallAudit-009: single-timestamp saturation pin does NOT spin —
// a saturated batch whose max UpdatedAtUtc never advances past `since`
// breaks the within-tick drain after one page (no unbounded re-pull),
// and still upserts the rows it saw.
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_SingleTimestampSaturation_DoesNotSpin_MakesNoProgressGracefully()
{
var siteId = "siteA";
// A burst sharing ONE exact UpdatedAtUtc that saturates the batch — the
// inclusive max-timestamp cursor cannot advance, so an unbounded
// continuation loop would re-pull this identical window forever.
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var r1 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var r2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
var client = new SaturatedPinPullClient(new[] { r1, r2 });
var repo = new RecordingRepo();
// Long interval so AT MOST one tick fires in the assert window — lets us
// bound the WITHIN-tick pull count. A no-progress pin must break after a
// single page, NOT loop up to MaxReconciliationPagesPerTick (50).
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
AwaitAssert(
() => Assert.True(client.CallCount >= 1, "the first reconciliation tick should have pulled"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// The rows it saw were still upserted (idempotent mirror refresh).
Assert.True(repo.Upserted.ContainsKey(r1.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(r2.TrackedOperationId));
// Critical SiteCallAudit-009 invariant: the within-tick drain BROKE on the
// no-progress pin rather than looping to the 50-page ceiling. With a 2s
// tick interval, only the first tick has fired in the window, so the pull
// count reflects ONE tick's within-loop behaviour. A correct break yields
// 1 pull for that tick; we allow a small margin for a possible second tick
// edge, but it must be far below the 50-page within-tick ceiling.
Assert.True(client.CallCount < 10,
$"a single-timestamp saturation pin must break the within-tick drain, not spin to the "
+ $"page ceiling; got {client.CallCount} pulls (an unbounded within-tick loop would be 50+)");
}
// ---------------------------------------------------------------------
// 7. Task 16: a keyset-honouring site drains a single-timestamp burst
// LARGER than the batch size in ONE tick — the composite (UpdatedAtUtc,
// TrackedOperationId) cursor advances by id even when every row shares
// one exact timestamp, so the tail no longer pins forever.
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_KeysetCursor_DrainsSingleTimestampBurstBeyondBatchSize()
{
var siteId = "siteA";
// 2 × batchSize rows all sharing ONE exact UpdatedAtUtc: the pre-keyset
// inclusive-timestamp cursor could never advance past this window.
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var rows = Enumerable.Range(0, 4)
.Select(_ => NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts))
.ToList();
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
var client = new KeysetHonoringPullClient(rows);
var repo = new RecordingRepo();
// batchSize = 2 → the 4-row burst needs the id tiebreak to page through.
// Slow tick so the full drain must be the within-tick continuation loop,
// not multiple ticks.
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
AwaitAssert(
() =>
{
foreach (var r in rows)
{
Assert.True(repo.Upserted.ContainsKey(r.TrackedOperationId),
"every row in the single-timestamp burst must reconcile via the "
+ "composite keyset cursor within one tick");
}
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Two pages (2 + 2) drained the burst; well under the 50-page ceiling.
Assert.True(client.CallCount is >= 2 and < 10,
$"expected the burst to drain in a couple of keyset pages, got {client.CallCount}");
}
// ---------------------------------------------------------------------
// 8. Task 16: a LEGACY site that ignores after_id can never be drained, so
// the actor latches and publishes SiteCallReconciliationPinnedChanged
// instead of silently logging (the dead-end is now health-observable).
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_LegacySiteIgnoresAfterId_PublishesPinnedChanged()
{
var siteId = "siteLegacy";
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var r1 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var r2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteLegacy:8083"));
var client = new LegacyIgnoresAfterIdPullClient(new[] { r1, r2 });
var repo = new RecordingRepo();
// Subscribe BEFORE the actor starts ticking so the transition isn't missed.
Sys.EventStream.Subscribe(TestActor, typeof(SiteCallReconciliationPinnedChanged));
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
var evt = ExpectMsg<SiteCallReconciliationPinnedChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(siteId, evt.SiteId);
Assert.True(evt.Pinned, "a legacy site that ignores after_id must publish Pinned=true");
}
// ---------------------------------------------------------------------
// 9. WP2.2: the reconciliation drain runs OFF the mailbox, so ingest,
// query and KPI messages are answered while a long post-outage
// catch-up is still pulling.
// ---------------------------------------------------------------------
/// <summary>
/// Pull client whose first call blocks until released, simulating a
/// post-outage catch-up that takes far longer than the caller's Ask timeout.
/// </summary>
private sealed class BlockingPullClient : IPullSiteCallsClient
{
private readonly TaskCompletionSource _release =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Completes once the drain has actually started pulling.</summary>
public Task Entered => _entered.Task;
public void Release() => _release.TrySetResult();
public async Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
_entered.TrySetResult();
await _release.Task.ConfigureAwait(false);
return new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false);
}
}
[Fact]
public async Task ReconciliationDrain_InFlight_DoesNotBlockIngestUpsert()
{
// The drain used to run inside ReceiveAsync, which occupies the actor for
// its whole duration. A post-outage catch-up (every site, many paged
// network pulls, one upsert per row) therefore parked telemetry ingest,
// UI queries and KPI Asks behind it — and those callers timed out rather
// than queued, so a slow site could make central look dead. This pins the
// fix: with the drain off-mailbox behind a single-flight guard, an ingest
// Ask completes promptly while the pull is still blocked.
var siteId = "siteSlow";
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083"));
var client = new BlockingPullClient();
var repo = new RecordingRepo();
var actor = CreateActor(sites, client, repo, FastTickOptions());
// Wait until the drain is genuinely in flight and blocked inside PullAsync.
await client.Entered.WaitAsync(TimeSpan.FromSeconds(5));
// The mailbox must still be serving. A generous-but-finite budget: this
// fails at the pre-fix behaviour (the reply only arrives once the pull
// unblocks, which never happens until Release below).
var id = TrackedOperationId.New();
var reply = await actor.Ask<UpsertSiteCallReply>(
new UpsertSiteCallCommand(NewRow(id, sourceSite: siteId)),
TimeSpan.FromSeconds(3));
Assert.True(reply.Accepted);
Assert.Equal(id, reply.TrackedOperationId);
// Let the drain finish so the actor shuts down cleanly.
client.Release();
}
[Fact]
public async Task ReconciliationTicks_DoNotOverlap_WhileADrainIsInFlight()
{
// Single-flight guard: with a 100 ms tick and a drain blocked for far
// longer, every subsequent tick must be dropped rather than starting a
// second concurrent pass — overlapping passes would race on the per-site
// cursor and pinned-latch dictionaries the drain mutates.
var siteId = "siteSlow";
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083"));
var client = new CountingBlockingPullClient();
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
await client.Entered.WaitAsync(TimeSpan.FromSeconds(5));
// Several tick intervals elapse while the first pass is still blocked.
await Task.Delay(TimeSpan.FromMilliseconds(600));
Assert.Equal(1, client.CallCount);
client.Release();
}
/// <summary>
/// An injected repository is ONE instance — typically wrapping one
/// <c>DbContext</c> — shared by the mailbox handlers and the off-mailbox
/// reconciliation/purge passes, and <c>DbContext</c> forbids concurrent
/// operations. Every call the actor makes through an injected repository must
/// therefore be serialized, so a drain's upserts never overlap an ingest
/// upsert arriving on the mailbox. (Production is unaffected: each message and
/// each pass resolves its own scope, hence its own context.)
/// </summary>
[Fact]
public async Task InjectedRepository_IsNeverCalledConcurrently_ByDrainAndMailbox()
{
var siteId = "siteBusy";
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteBusy:8083"));
// One page of rows for the drain to upsert, each call held open long
// enough that a concurrent mailbox upsert would land inside it.
var pulled = Enumerable.Range(0, 6)
.Select(_ => NewRow(TrackedOperationId.New(), siteId))
.ToArray();
var client = new OneBatchThenEmptyPullClient(pulled);
var repo = new ConcurrencyDetectingRepo(TimeSpan.FromMilliseconds(40));
var actor = CreateActor(sites, client, repo, FastTickOptions());
// Once the pull has been served the drain is upserting; flood the mailbox
// with ingest commands so the two writers overlap in wall-clock time.
await client.Entered.WaitAsync(TimeSpan.FromSeconds(5));
var asks = Enumerable.Range(0, 6)
.Select(_ => actor.Ask<UpsertSiteCallReply>(
new UpsertSiteCallCommand(NewRow(TrackedOperationId.New(), siteId)),
TimeSpan.FromSeconds(10)))
.ToArray();
await Task.WhenAll(asks);
Assert.All(asks, ask => Assert.True(ask.Result.Accepted));
// The drain's own upserts must have run in the same window.
AwaitAssert(
() => Assert.True(repo.UpsertCount >= 12, $"expected both writers to have run; saw {repo.UpsertCount}"),
TimeSpan.FromSeconds(5));
Assert.Equal(1, repo.MaxObservedConcurrency);
}
/// <summary>
/// Serves one page of rows on the first pull and nothing afterwards, so the
/// drain has real upsert work to do and then settles.
/// </summary>
private sealed class OneBatchThenEmptyPullClient : IPullSiteCallsClient
{
private readonly SiteCall[] _rows;
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
public OneBatchThenEmptyPullClient(SiteCall[] rows) => _rows = rows;
public Task Entered => _entered.Task;
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
var first = Interlocked.Increment(ref _callCount) == 1;
_entered.TrySetResult();
return Task.FromResult(new PullSiteCallsResponse(
first ? _rows : Array.Empty<SiteCall>(), MoreAvailable: false));
}
}
/// <summary>
/// Records the peak number of overlapping repository calls. Each call is held
/// open briefly so an overlap, if the actor allows one, is observed rather
/// than missed by timing luck.
/// </summary>
private sealed class ConcurrencyDetectingRepo : ISiteCallAuditRepository
{
private readonly TimeSpan _hold;
private int _inFlight;
private int _maxObserved;
private int _upsertCount;
public ConcurrencyDetectingRepo(TimeSpan hold) => _hold = hold;
public int MaxObservedConcurrency => Volatile.Read(ref _maxObserved);
public int UpsertCount => Volatile.Read(ref _upsertCount);
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
Interlocked.Increment(ref _upsertCount);
await TrackAsync().ConfigureAwait(false);
}
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return null;
}
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return Array.Empty<SiteCall>();
}
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return 0;
}
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
}
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return Array.Empty<SiteCallSiteKpiSnapshot>();
}
public async Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return Array.Empty<SiteCallNodeKpiSnapshot>();
}
private async Task TrackAsync()
{
var current = Interlocked.Increment(ref _inFlight);
// Monotonic max without a lock.
int seen;
while (current > (seen = Volatile.Read(ref _maxObserved))
&& Interlocked.CompareExchange(ref _maxObserved, current, seen) != seen)
{
// Another thread moved the max; re-read and retry.
}
try
{
await Task.Delay(_hold).ConfigureAwait(false);
}
finally
{
Interlocked.Decrement(ref _inFlight);
}
}
}
/// <summary>
/// <see cref="BlockingPullClient"/> that also counts invocations, so a test
/// can prove no second pass started while the first was blocked.
/// </summary>
private sealed class CountingBlockingPullClient : IPullSiteCallsClient
{
private readonly TaskCompletionSource _release =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
public Task Entered => _entered.Task;
public int CallCount => Volatile.Read(ref _callCount);
public void Release() => _release.TrySetResult();
public async Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
_entered.TrySetResult();
await _release.Task.ConfigureAwait(false);
return new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false);
}
}
}