perf(dcl): coalesce MxGateway alarm stream reopens across a subscribe burst (review 2026-10-21 02/P3)

This commit is contained in:
Joseph Doherty
2026-10-22 06:44:13 -04:00
parent e6406c494c
commit d8d44e0b87
7 changed files with 189 additions and 36 deletions
@@ -123,6 +123,61 @@ public class MxGatewaySessionScopedHandleTests
Assert.Equal(1, Volatile.Read(ref delivered));
}
/// <summary>
/// Review 2026-10-21 02/P3: a burst of alarm subscribes (a staggered deploy batch of N
/// alarm-bound instances) is coalesced into ONE stream reopen on the union prefix instead of N
/// close/reopen cycles, each with its own connection-wide snapshot replay. Every source in the
/// burst is still covered by that one fresh snapshot (DC-5's standing-state guarantee).
/// </summary>
[Fact]
public async Task AlarmSubscribeBurst_IsCoalescedIntoOneStreamReopen()
{
var fake = new FakeMxGatewayClient();
var adapter = new MxGatewayDataConnection(
fake, NullLogger<MxGatewayDataConnection>.Instance,
alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(300));
await adapter.ConnectAsync(Details());
// Spaced like actor-delivered subscribes, so an un-coalesced reopen really reaches the
// gateway each time (a reopen superseded before its task runs never would).
foreach (var source in new[] { "Area1.Tank1", "Area1.Tank2", "Area1.Tank3", "Area1.Tank4", "Area1.Tank5" })
{
await adapter.SubscribeAlarmsAsync(source, null, _ => { });
await Task.Delay(20);
}
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count >= 1; });
await Task.Delay(400); // well past the window: no trailing reopens
lock (fake.AlarmStreamPrefixes)
{
Assert.Single(fake.AlarmStreamPrefixes);
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[0]);
}
}
/// <summary>
/// A subscribe arriving after the coalesced reopen has fired still gets its own fresh
/// snapshot — coalescing never swallows a source.
/// </summary>
[Fact]
public async Task AlarmSubscribe_AfterTheWindow_StillReopens()
{
var fake = new FakeMxGatewayClient();
var adapter = new MxGatewayDataConnection(
fake, NullLogger<MxGatewayDataConnection>.Instance,
alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(50));
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 1; });
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { });
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 2; });
lock (fake.AlarmStreamPrefixes)
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
}
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 30_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
@@ -39,4 +39,27 @@ public class DataConnectionOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("SeedReadMaxAttempts", result.FailureMessage);
}
/// <summary>Verifies that an out-of-range MxGateway alarm-reopen debounce is rejected (review 2026-10-21 02/P3).</summary>
/// <param name="milliseconds">The debounce under test.</param>
[Theory]
[InlineData(-1)]
[InlineData(5001)]
public void OutOfRangeMxAlarmStreamReopenDebounce_IsRejected(int milliseconds)
{
var result = Validate(new DataConnectionOptions
{
MxAlarmStreamReopenDebounce = TimeSpan.FromMilliseconds(milliseconds)
});
Assert.True(result.Failed);
Assert.Contains("MxAlarmStreamReopenDebounce", result.FailureMessage);
}
/// <summary>Verifies that a zero debounce (reopen per subscribe) is accepted.</summary>
[Fact]
public void ZeroMxAlarmStreamReopenDebounce_IsAccepted()
{
var result = Validate(new DataConnectionOptions { MxAlarmStreamReopenDebounce = TimeSpan.Zero });
Assert.True(result.Succeeded, result.FailureMessage);
}
}