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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user