feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth

Design §3.6 invariants #1/#2. A Sparkplug alias is scoped to ONE birth: after a
rebirth an edge node may point alias 5 at a different metric. So authored tags
bind by the stable metric NAME and the alias is a per-birth cache only —
RebuildFromBirth builds both indexes fresh and publishes them in one assignment,
never merging or upserting, including for an empty birth (which legitimately
clears the scope).

BirthCache keys scopes by the full (group, edgeNode, device?) triple and applies
the spec's node→device rules: an NBIRTH invalidates every DBIRTH under that node
(returning their metric sets for the STALE fan-out), a death evicts rather than
blanks so data-before-rebirth stays detectable. Reads are lock-free on MQTTnet's
dispatcher thread — an immutable snapshot swapped atomically per scope, the same
discipline MqttSubscriptionManager.AuthoredTable uses, with rebuilds landing in
place so a held table reference always observes the newest birth.

Running values are deliberately NOT stored here — LastValueCache owns those,
keyed by RawPath. SparkplugMetricBinding.Reinterpret exposes the birth datatype
to the codec's two's-complement fix, since a DATA metric carries no datatype.

Falsifiability: mutating RebuildFromBirth to merge reddened 5/19 tests (both
plan invariants); making the alias carry metric identity across a birth reddened
3/19, including the headline alias-reuse case.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 21:28:41 -04:00
parent b245f380b1
commit 2afef00eaa
3 changed files with 890 additions and 0 deletions
@@ -0,0 +1,393 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="AliasTable"/> + <see cref="BirthCache"/> (Task 18) — design doc §3.6
/// invariants #1 and #2.
/// </summary>
/// <remarks>
/// <para>
/// <b>What is actually being pinned here.</b> Sparkplug's alias mechanism exists to shrink DATA
/// payloads: a BIRTH declares <c>name ↔ alias</c> and every subsequent DATA metric carries only
/// the alias. The alias is scoped to <i>one birth</i> — after a rebirth an edge node is free to
/// assign alias 5 to an entirely different metric. If this driver merged alias tables across
/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
/// would silently start routing Pressure readings into a Temperature tag: <b>good quality,
/// plausible values, completely wrong</b>. It is invisible without a test that specifically
/// reuses an alias across a rebirth, which is what
/// <see cref="Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias"/> and
/// <see cref="DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName"/> do.
/// </para>
/// <para>
/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth
/// it came from when its <i>edge node</i> re-births — pinned by
/// <see cref="NodeBirth_InvalidatesEveryDeviceUnderThatNode"/>.
/// </para>
/// </remarks>
public sealed class AliasBindingTests
{
// ---- AliasTable: the two load-bearing invariants ----
/// <summary>
/// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
/// metric, and the table must report the <b>new</b> metric — i.e. the birth replaced the map
/// rather than merging into it.
/// </summary>
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Temperature");
// Rebirth: alias 5 now means a DIFFERENT metric.
table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
table.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
/// <summary>
/// §3.6 invariant #2: an alias the new birth did not declare must be <b>gone</b>, not carried
/// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
/// </summary>
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);
table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
table.TryResolve(2, out _).ShouldBeTrue();
table.Count.ShouldBe(1);
}
/// <summary>
/// The degenerate replace path: a birth carrying no usable metric still <b>clears</b> the table.
/// An implementation that short-circuits on an empty input (or merges) keeps every stale alias
/// alive behind a birth that declared none of them.
/// </summary>
[Fact]
public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);
table.RebuildFromBirth([]);
table.Count.ShouldBe(0);
table.TryResolve(1, out _).ShouldBeFalse();
table.TryResolve(2, out _).ShouldBeFalse();
table.Metrics.ShouldBeEmpty();
}
/// <summary>
/// The mirror of the headline case: the <i>metric</i> stays put and its alias moves. Binding is
/// by name, so the tag keeps resolving; only the per-birth alias cache changed.
/// </summary>
[Fact]
public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
byName!.Alias.ShouldBe(9ul);
table.Resolve(9)!.Name.ShouldBe("Temperature");
table.TryResolve(5, out _).ShouldBeFalse();
}
// ---- AliasTable: malformed births ----
/// <summary>
/// A BIRTH metric carrying an alias but no name is malformed and is <b>rejected</b>, never
/// stored alias-only: the name is the binding key, so an alias-only entry could only ever
/// resolve to a binding no authored tag can match — or, worse, match a tag whose authored
/// metric name happens to be blank.
/// </summary>
[Fact]
public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]);
result.Accepted.ShouldBe(0);
result.RejectedUnnamed.ShouldBe(2);
result.HasAnomaly.ShouldBeTrue();
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolve(6, out _).ShouldBeFalse();
}
/// <summary>
/// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
/// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
/// in the alias index.
/// </summary>
[Fact]
public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBeNull();
table.Metrics.Count.ShouldBe(1);
}
/// <summary>
/// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
/// flip between two different metrics — precisely the mis-route this whole type exists to
/// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
/// alias and can request a rebirth) while both metrics stay bindable by their names.
/// </summary>
[Fact]
public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);
result.AliasCollisions.ShouldBe(1);
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolveByName("Temperature", out _).ShouldBeTrue();
table.TryResolveByName("Pressure", out _).ShouldBeTrue();
}
/// <summary>
/// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
/// it would take the tag dark entirely), reported so the consumer can warn.
/// </summary>
[Fact]
public void DuplicateNameInOneBirth_IsLastWins_AndReported()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);
result.DuplicateNames.ShouldBe(1);
result.Accepted.ShouldBe(1);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBe(6ul);
}
/// <summary>
/// A BIRTH metric with no datatype is malformed, but it is <b>kept</b> as
/// <see cref="SparkplugDataType.Unknown"/> rather than rejected: rejecting it makes every DATA
/// message for its alias an unknown-alias event, which the ingest state machine answers with a
/// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
/// as Unknown, the typed layer refuses the value once (<c>ToDriverDataType()</c> returns null)
/// and nothing storms.
/// </summary>
[Fact]
public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);
result.Accepted.ShouldBe(1);
result.RejectedUnnamed.ShouldBe(0);
table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
}
/// <summary>
/// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
/// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
/// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
/// plausible, Good quality, invisible.
/// </summary>
[Fact]
public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);
// What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
var raw = unchecked((uint)-42);
table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
}
/// <summary>A null birth sequence is a caller bug, not a birth — it must not silently clear the table.</summary>
[Fact]
public void RebuildFromBirth_Null_Throws() =>
Should.Throw<ArgumentNullException>(() => new AliasTable().RebuildFromBirth(null!));
// ---- BirthCache: scoping ----
/// <summary>
/// Sparkplug spec: an NBIRTH means the edge node (re)started, so <b>every</b> DBIRTH previously
/// published under it is invalid until re-published. Keeping a device's alias table across its
/// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
/// </summary>
[Fact]
public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
// The invalidated devices come back with their metric sets so the consumer can fan STALE out
// without a lookup against a table that has already been evicted.
outcome.InvalidatedDevices.Count.ShouldBe(2);
outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
.ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
}
/// <summary>An NBIRTH is scoped to its own edge node — another node's devices are untouched.</summary>
[Fact]
public void NodeBirth_DoesNotTouchAnotherNodesDevices()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
outcome.InvalidatedDevices.ShouldBeEmpty();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// <summary>A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.</summary>
[Fact]
public void DeviceBirth_ReplacesOnlyThatDevicesTable()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
}
/// <summary>
/// Key completeness. A cache keyed on the device id alone — or on <c>node/device</c> without the
/// group — would let one plant's <c>Filler1</c> answer for another's, which is the same
/// wrong-but-plausible mis-route as an alias collision.
/// </summary>
[Fact]
public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.Count.ShouldBe(3);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
}
/// <summary>
/// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
/// cache and hands back the metric sets, which is what the consumer fans STALE out over.
/// </summary>
[Fact]
public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
var removed = cache.ForgetNode("Plant1", "EdgeA");
removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// <summary>A DDEATH takes out exactly one device and leaves its node's own metrics live.</summary>
[Fact]
public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();
removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
removed.Scope.DeviceId.ShouldBe("Filler1");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
}
/// <summary>
/// After a death the scope is <b>absent</b>, not merely empty — that is what makes DATA arriving
/// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
/// unknown-alias drop against a table that still looks alive.
/// </summary>
[Fact]
public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Count.ShouldBe(0);
}
/// <summary>
/// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
/// through the scoped path the ingest state machine actually calls.
/// </summary>
[Fact]
public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
{
var cache = new BirthCache();
var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
/// <summary>Builds a BIRTH metric the way <see cref="SparkplugCodec"/> projects one.</summary>
private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
new(
Name: name,
Alias: alias,
DataType: dataType,
TimestampMs: null,
ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
Value: value);
/// <summary>Builds a malformed BIRTH metric that declares no datatype at all.</summary>
private static SparkplugMetric Untyped(string name, ulong alias) =>
new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
}