perf(store-and-forward): post audit-observer notifications to an ordered single-reader channel, latency-isolating the sweep

This commit is contained in:
Joseph Doherty
2026-07-08 21:19:59 -04:00
parent 5fe1b9747b
commit c6ea332c94
2 changed files with 170 additions and 5 deletions
@@ -452,4 +452,69 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
var msg = await _storage.GetMessageByIdAsync(trackedId.ToString());
Assert.Null(msg);
}
// ── Task 19: audit-observer decoupled from the sweep via an ordered channel ──
private sealed class DelegatingObserver : ICachedCallLifecycleObserver
{
private readonly Func<CachedCallAttemptContext, Task> _onNotify;
public DelegatingObserver(Func<CachedCallAttemptContext, Task> onNotify) => _onNotify = onNotify;
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
=> _onNotify(context);
}
[Fact]
public async Task SlowObserver_DoesNotBlockTheSweep_AndEventsArriveInOrder()
{
var gate = new TaskCompletionSource();
var observed = new List<CachedCallAttemptOutcome>();
var observer = new DelegatingObserver(async ctx =>
{
await gate.Task; // first call blocks until released
lock (observed) observed.Add(ctx.Outcome);
});
// Fresh service over its own storage so StartAsync's pump/timer is isolated
// from the shared _service.
var connStr = $"Data Source=SlowObs_{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
using var keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var service = new StoreAndForwardService(
storage,
new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromHours(1), // timer never fires in-test
},
NullLogger<StoreAndForwardService>.Instance,
replication: null,
cachedCallObserver: observer,
siteId: "site-77");
await service.StartAsync();
try
{
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero,
messageId: TrackedOperationId.New().ToString());
var sweep = service.RetryPendingMessagesAsync();
await sweep.WaitAsync(TimeSpan.FromSeconds(5)); // pre-fix: blocks on gate → times out
gate.SetResult();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
lock (observed) if (observed.Count == 1) break;
await Task.Delay(10);
}
lock (observed) Assert.Single(observed);
}
finally { await service.StopAsync(); }
}
}