perf(store-and-forward): post audit-observer notifications to an ordered single-reader channel, latency-isolating the sweep
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user