Merge review R5 fixes: MTConnect session publish + generation bump atomic, superseded pumps joined on stop; MQTT writes encode the birth's own Sparkplug datatype (unsupported birth types refused BadNotSupported); AbLegacy dead init join removed + Dispose join test; MQTT QoS1 and Postgres key-normalisation documented

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:53:18 -04:00
9 changed files with 287 additions and 18 deletions
@@ -51,4 +51,34 @@ public sealed class AbLegacyShutdownJoinTests
factory.Tags["S:0"].Disposed.ShouldBeTrue();
}
/// <summary>The synchronous <c>Dispose()</c> path joins the parked loop too (it used to cancel and dispose under it).</summary>
[Fact]
public async Task Dispose_WaitsForProbeLoopParkedInInit_ThenProbeRuntimeIsDisposed()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new ParkedInitTag(p, entered, release),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0" },
}, "drv-join-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var dispose = Task.Run(drv.Dispose);
await Task.Delay(300);
dispose.IsCompleted.ShouldBeFalse("Dispose must join the probe loop parked in runtime init");
// Positive control: releasing the parked call lets Dispose finish, with the probe runtime released.
release.SetResult();
await dispose.WaitAsync(TimeSpan.FromSeconds(5));
factory.Tags["S:0"].Disposed.ShouldBeTrue();
}
}
@@ -35,6 +35,7 @@ public sealed class MTConnectPumpGenerationTests
"mt-gen",
_ => clients.Dequeue());
await driver.InitializeAsync("{}", Ct);
Task[] retired = [];
var blockOnce = 0;
var blocking = new ManualResetEventSlim(false);
@@ -75,6 +76,11 @@ public sealed class MTConnectPumpGenerationTests
blocking.Set();
await restart.WaitAsync(Watchdog, Ct);
// The pump retired by the restart is retained so shutdown can join it (it used to be dropped
// un-awaited and could write health after the new session was Healthy).
retired = driver.SupersededPumpTasks;
retired.ShouldNotBeEmpty();
// The restart installed a new client; the standing subscription must be streaming from IT.
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
clientA.IsDisposed.ShouldBeTrue();
@@ -84,6 +90,10 @@ public sealed class MTConnectPumpGenerationTests
blocking.Set();
await driver.ShutdownAsync(CancellationToken.None);
}
// Shutdown joined every retired pump and forgot them.
retired.ShouldAllBe(t => t.IsCompleted);
driver.SupersededPumpTasks.ShouldBeEmpty();
}
}
@@ -287,6 +287,56 @@ public sealed class MqttWriteEncoderTests
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Reference), "ref", out _)
.ShouldBe(MqttWriteEncodeError.UnsupportedDataType);
// ---- Birth-typed writes keep the birth's OWN Sparkplug datatype (review R5 MINOR-4) ----------
private static Payload.Types.Metric EncodeBirth(TahuDataType birth, object value)
{
var error = MqttWriteEncoder.TryEncode(
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Float64) with { DataTypeAuthored = false }, value,
out var encoded, birth.ToDriverDataType(), birth);
error.ShouldBe(MqttWriteEncodeError.None);
return Payload.Parser.ParseFrom(encoded.Payload).Metrics.Single();
}
[Theory]
[InlineData(TahuDataType.Int8, (sbyte)-5)]
[InlineData(TahuDataType.Uint8, (byte)200)]
[InlineData(TahuDataType.Text, "hello")]
[InlineData(TahuDataType.Uuid, "123e4567-e89b-12d3-a456-426614174000")]
public void A_birth_typed_write_is_commanded_with_the_births_own_datatype(TahuDataType birth, object value)
=> ((TahuDataType)EncodeBirth(birth, value).Datatype).ShouldBe(birth);
[Fact]
public void A_birth_typed_bytes_write_carries_bytes_with_the_bytes_datatype()
{
var metric = EncodeBirth(TahuDataType.Bytes, new byte[] { 1, 2, 3 });
((TahuDataType)metric.Datatype).ShouldBe(TahuDataType.Bytes);
metric.BytesValue.ToByteArray().ShouldBe([1, 2, 3]);
}
[Theory]
[InlineData(TahuDataType.Int32Array)]
[InlineData(TahuDataType.DoubleArray)]
[InlineData(TahuDataType.DataSet)]
[InlineData(TahuDataType.Template)]
public void A_birth_type_with_no_command_encoding_is_refused_never_sent_as_another_type(TahuDataType birth)
{
MqttWriteEncoder.TryEncode(
MqttMode.SparkplugB, Sparkplug("Filler1") with { DataTypeAuthored = false }, 1, out var encoded,
birth.ToDriverDataType(), birth)
.ShouldBe(MqttWriteEncodeError.UnsupportedDataType);
encoded.Payload.ShouldBeNull();
}
[Fact]
public void An_authored_type_still_wins_over_the_birth_type()
{
var def = Sparkplug("Filler1", DriverDataType.Int32) with { DataTypeAuthored = true };
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, null, TahuDataType.Text)
.ShouldBe(MqttWriteEncodeError.None);
((TahuDataType)Payload.Parser.ParseFrom(encoded.Payload).Metrics.Single().Datatype).ShouldBe(TahuDataType.Int32);
}
[Fact]
public void The_mode_decides_the_shape_not_the_blob()
{