fix(site-call-audit): reconciliation pull serves only un-acked tracking rows (node-local ack ledger), batched page upsert, prune removed sites' cursors (review 2026-10-21 06/PERF-2 + lane H follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 09:35:30 -04:00
parent e69be1654f
commit 88273693f9
12 changed files with 789 additions and 10 deletions
@@ -231,6 +231,9 @@ public class SiteCallAuditReconciliationTests : TestKit
/// <summary>Gets the number of upsert calls received.</summary>
public int UpsertCallCount { get; private set; }
/// <summary>Gets the row count of every batched (page) upsert received.</summary>
public List<int> UpsertManyPageSizes { get; } = new();
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
@@ -239,6 +242,23 @@ public class SiteCallAuditReconciliationTests : TestKit
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpsertManyAsync(IReadOnlyList<SiteCall> siteCalls, CancellationToken ct = default)
{
lock (UpsertManyPageSizes)
{
UpsertManyPageSizes.Add(siteCalls.Count);
}
foreach (var siteCall in siteCalls)
{
UpsertCallCount++;
Upserted[siteCall.TrackedOperationId] = siteCall;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult(Upserted.TryGetValue(id, out var row) ? row : null);
@@ -706,6 +726,148 @@ public class SiteCallAuditReconciliationTests : TestKit
}
}
// ---------------------------------------------------------------------
// Review 2026-10-21 06/PERF-2: a pulled page is written through ONE batched
// upsert, not one repository call per row.
// ---------------------------------------------------------------------
/// <summary>Verifies that a reconciliation page is written through a single batched upsert.</summary>
[Fact]
public void ReconciliationTick_WritesEachPageThroughOneBatchedUpsert()
{
var siteId = "siteBatch";
var rows = Enumerable.Range(0, 3)
.Select(i => NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: DateTime.UtcNow.AddSeconds(i)))
.ToArray();
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteBatch:8083"));
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(rows, MoreAvailable: false));
var repo = new RecordingRepo();
var actor = CreateActor(sites, client, repo, FastTickOptions());
try
{
AwaitAssert(
() =>
{
Assert.Equal(3, repo.Upserted.Count);
lock (repo.UpsertManyPageSizes)
{
Assert.Contains(3, repo.UpsertManyPageSizes);
}
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
finally
{
StopTicker(actor);
}
}
/// <summary>Enumerator whose site list a test can swap between ticks.</summary>
private sealed class MutableEnumerator : ISiteEnumerator
{
private volatile IReadOnlyList<SiteEntry> _sites;
private int _calls;
/// <summary>Creates the enumerator with an initial site list.</summary>
/// <param name="sites">The initial sites.</param>
public MutableEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <summary>Gets the number of enumerations performed so far.</summary>
public int Calls => Volatile.Read(ref _calls);
/// <summary>Replaces the site list returned from the next enumeration on.</summary>
/// <param name="sites">The new sites.</param>
public void Set(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(_sites);
}
}
/// <summary>
/// Verifies that a site the enumerator stops returning has its cursor and pinned latch
/// pruned: the pinned site publishes Pinned=false, and when it reappears its pull restarts
/// from <see cref="DateTime.MinValue"/> (lane H follow-up to review 2026-10-21).
/// </summary>
[Fact]
public void ReconciliationTick_RemovedSite_PrunesCursorAndPinnedLatch()
{
var siteId = "siteGone";
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var rows = new[]
{
NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts),
NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts),
};
var entry = new SiteEntry(siteId, "http://siteGone:8083");
var sites = new MutableEnumerator(entry);
var client = new LegacyIgnoresAfterIdPullClient(rows);
var recorder = new RecordingPullClient(client);
var repo = new RecordingRepo();
// A dedicated probe, not TestActor: the re-added legacy site pins again, and that
// event must not land in StopTicker's ExpectTerminated.
var pinnedProbe = CreateTestProbe();
Sys.EventStream.Subscribe(pinnedProbe.Ref, typeof(SiteCallReconciliationPinnedChanged));
var actor = CreateActor(sites, recorder, repo, new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
ReconciliationBatchSize = 2,
});
try
{
Assert.True(pinnedProbe.ExpectMsg<SiteCallReconciliationPinnedChanged>(TimeSpan.FromSeconds(5)).Pinned);
sites.Set();
var unpinned = pinnedProbe.ExpectMsg<SiteCallReconciliationPinnedChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(siteId, unpinned.SiteId);
Assert.False(unpinned.Pinned);
var callsBeforeReturn = recorder.Calls.Count;
sites.Set(entry);
AwaitAssert(
() =>
{
var later = recorder.Calls.Skip(callsBeforeReturn).ToList();
Assert.NotEmpty(later);
Assert.Equal(DateTime.MinValue, later[0].SinceUtc);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
finally
{
Sys.EventStream.Unsubscribe(pinnedProbe.Ref);
StopTicker(actor);
}
}
/// <summary>Wraps a pull client and records the cursor of every pull, thread-safely.</summary>
private sealed class RecordingPullClient(IPullSiteCallsClient inner) : IPullSiteCallsClient
{
private readonly List<(DateTime SinceUtc, string? AfterId)> _calls = new();
/// <summary>Gets a snapshot of every pull cursor recorded so far.</summary>
public IReadOnlyList<(DateTime SinceUtc, string? AfterId)> Calls
{
get { lock (_calls) { return _calls.ToList(); } }
}
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
lock (_calls) { _calls.Add((sinceUtc, afterId)); }
return inner.PullAsync(siteId, sinceUtc, afterId, batchSize, ct);
}
}
// ---------------------------------------------------------------------
// 9. The reconciliation drain runs OFF the mailbox, so ingest,
// query and KPI messages are answered while a long post-outage
@@ -116,6 +116,10 @@ internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditReposit
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
/// <inheritdoc />
public Task UpsertManyAsync(IReadOnlyList<SiteCall> siteCalls, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertManyAsync(siteCalls, ct));
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));