feat(mqtt): wire sparkplug.birthObservationWindowSeconds into the browse surface (R06:M-4)

The authored window was persisted and consumed by nothing. It now (clamped 1-120 s)
extends the Sparkplug OpenBudget, bounds a passive UntilStable-style settle on open
(return after a 1 s quiet spell in NBIRTH/DBIRTH traffic), and bounds how long an
operator rebirth request waits for the births it asked for before returning, so the
re-rendered tree carries them. A caller deadline during the wait still reports the
publish.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:33:47 -04:00
parent 57ffbf79e5
commit d67396cc6d
4 changed files with 284 additions and 10 deletions
@@ -0,0 +1,145 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// <b>R06:M-4.</b> <c>sparkplug.birthObservationWindowSeconds</c> was authored, range-checked and
/// persisted by the AdminUI and consumed by nothing. It now bounds how long the Sparkplug browse surface
/// collects NBIRTH/DBIRTH traffic before calling the observed set stable: the open budget grows by it,
/// the open lingers while births are still arriving, and an operator rebirth request waits (bounded) for
/// the births it asked for, so the tree the picker re-renders already carries them.
/// </summary>
public sealed class MqttBirthObservationWindowTests
{
private static MqttDriverOptions Sparkplug(int windowSeconds, int connectTimeoutSeconds = 10) => new()
{
Mode = MqttMode.SparkplugB,
ConnectTimeoutSeconds = connectTimeoutSeconds,
Sparkplug = new MqttSparkplugOptions { GroupId = "Plant1", BirthObservationWindowSeconds = windowSeconds },
};
[Fact]
public void OpenBudget_Sparkplug_AddsTheBirthObservationWindow()
{
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 15)).ShouldBe(TimeSpan.FromSeconds(10 + 15));
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 40)).ShouldBe(TimeSpan.FromSeconds(10 + 40));
}
[Fact]
public void OpenBudget_Plain_IgnoresTheSparkplugWindow()
{
var plain = Sparkplug(windowSeconds: 40) with { Mode = MqttMode.Plain };
MqttDriverBrowser.OpenBudget(plain).ShouldBe(TimeSpan.FromSeconds(10));
}
[Theory]
[InlineData(0, 1)]
[InlineData(-5, 1)]
[InlineData(15, 15)]
[InlineData(100_000, 120)]
public void BirthWindow_IsClamped(int configured, int expectedSeconds)
=> MqttDriverBrowser.BirthWindow(Sparkplug(configured)).ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
/// <summary>An idle plant costs one quiet interval, not the whole window.</summary>
[Fact]
public async Task Settle_WithNoBirths_ReturnsAfterOneQuietInterval()
{
var reads = 0;
await MqttBrowseSession.SettleBirthsAsync(
() => { reads++; return 0; },
baseline: 0,
awaitFirstBirth: false,
quiet: TimeSpan.FromMilliseconds(20),
window: TimeSpan.FromMinutes(5),
TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
reads.ShouldBe(1);
}
/// <summary>
/// While births keep arriving the settle keeps collecting; it returns on the first quiet interval
/// after the last one. Counted in reads, not wall-clock, so it cannot flake.
/// </summary>
[Fact]
public async Task Settle_KeepsCollectingWhileBirthsArrive_ThenReturnsOnQuiet()
{
var reads = 0;
long births = 0;
await MqttBrowseSession.SettleBirthsAsync(
() =>
{
reads++;
if (reads <= 5) births++; // five intervals with fresh births, then silence
return births;
},
baseline: 0,
awaitFirstBirth: false,
quiet: TimeSpan.FromMilliseconds(20),
window: TimeSpan.FromMinutes(5),
TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
reads.ShouldBe(6);
}
/// <summary>The window is a hard ceiling: a birth flood that never goes quiet still ends.</summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task Settle_NeverQuiet_EndsAtTheWindow()
{
long births = 0;
await MqttBrowseSession.SettleBirthsAsync(
() => ++births,
baseline: 0,
awaitFirstBirth: false,
quiet: TimeSpan.FromMilliseconds(20),
window: TimeSpan.FromSeconds(1),
TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
births.ShouldBeGreaterThan(1);
}
/// <summary>
/// A rebirth request waits for the birth it asked for. The fixed version cannot complete
/// synchronously (it awaits the settle), the unfixed one completes before the call returns — so the
/// <c>IsCompleted</c> check is deterministic, not a timing race. The birth then releases it.
/// </summary>
[Fact]
public async Task RequestRebirth_WaitsForTheRequestedBirths_ThenReturns()
{
await using var s = new MqttBrowseSession(
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
var request = s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
request.IsCompleted.ShouldBeFalse("the request must wait for the births it asked for");
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishCountForTest.ShouldBe(1);
}
/// <summary>
/// The wait is a courtesy, never a failure: the NCMD was already published, so a caller deadline
/// during the settle returns the published count instead of reporting an error for a request that
/// succeeded.
/// </summary>
[Fact]
public async Task RequestRebirth_CallerCancelsDuringTheSettle_StillReportsThePublish()
{
await using var s = new MqttBrowseSession(
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var request = s.RequestRebirthAsync("Plant1/EdgeA", cts.Token);
request.IsCompleted.ShouldBeFalse();
await cts.CancelAsync();
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
}
}