feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)
Task 21 — the design doc's §3.6 correctness core. `SparkplugIngestor` owns the
authored `(group, node, device?, metric) → RawPath` index, the live `BirthCache`,
one `SequenceTracker` per authored edge node, and the rebirth policy; it routes
NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/STATE into `OnDataChange` +
`LastValueCache`, or into a bounded, per-node-debounced rebirth NCMD.
Invariants held (and each pinned by a falsifiability control):
- Published reference is the RawPath, never a composed Sparkplug reference —
`DriverHostActor`'s dual-namespace fan-out is RawPath-keyed.
- Tags bind by stable metric NAME; the alias is only the per-birth lookup, so an
alias reused across a rebirth cannot mis-route.
- Every value goes through `SparkplugMetricBinding.Reinterpret` before publish.
- NDEATH never reaches `SequenceTracker.Accept` (it carries no seq); it is tied to
its birth by bdSeq instead.
- An invalid payload mutates nothing — no tracker, no alias table, no eviction.
- `HandleMessage` never throws on MQTTnet's dispatcher thread.
Supporting changes:
- `MqttTagDefinitionFactory.FromSparkplugTagConfig` — the driver had no way to
parse the Sparkplug descriptor keys at all; the P1 stub fields on
`MqttTagDefinition` were never populated. Mode selects the parser, never a
blob heuristic. `dataType` becomes optional in the Sparkplug shape (the birth
declares it), recorded via the new `DataTypeAuthored` flag.
- `MqttConnection` implements `IMqttPublishTransport` (bounded, refusal throws).
- `MqttDriver` dispatches every capability by mode, subscribes `spBv1.0/{group}/#`
(+ the configured STATE topic) once at connect, and re-subscribes + requests a
late-join rebirth on reconnect. `IngestIdentity` now names `MqttSparkplugOptions`,
closing the silent same-ingest gap its own ⚠️ comment warned about.
Primary-host STATE *publishing* is explicitly out of scope and warned about at
construction: it needs the retained-OFFLINE Last Will set at CONNECT time, and
shipping the ONLINE half alone is worse than shipping neither.
58 new tests (MQTT suite 455 → 513, all green).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -237,10 +237,17 @@ public sealed class MqttConnectRejectedException : Exception
|
||||
/// socket no later dispose could ever reach. Now the losing side of that race disposes the
|
||||
/// client it created and reports <see cref="ObjectDisposedException"/>.
|
||||
/// </para>
|
||||
/// Sparkplug rebirth-on-reconnect (Task 21, which hangs off <see cref="Reconnected"/>) is
|
||||
/// deliberately not implemented here.
|
||||
/// <para>
|
||||
/// <b>The publish leg is deliberately narrow.</b> <see cref="PublishAsync"/> exists to satisfy
|
||||
/// <see cref="Sparkplug.IMqttPublishTransport"/> — whose entire production caller is
|
||||
/// <see cref="Sparkplug.RebirthRequester"/> — not to make this a general MQTT publisher. This
|
||||
/// driver has no <c>IWritable</c> leg in v1, so a Sparkplug rebirth NCMD is the <i>only</i>
|
||||
/// outbound application message it ever sends. Sparkplug rebirth-on-reconnect itself is decided
|
||||
/// by <see cref="Sparkplug.SparkplugIngestor"/> hanging off <see cref="Reconnected"/>; this type
|
||||
/// only carries the bytes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport, Sparkplug.IMqttPublishTransport
|
||||
{
|
||||
private readonly string _driverId;
|
||||
|
||||
@@ -994,6 +1001,86 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes one application message, bounded by
|
||||
/// <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> so a broker that accepts PUBLISH and
|
||||
/// never completes it cannot park the caller — the same frozen-peer rule every other wait here
|
||||
/// follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A refused PUBLISH throws.</b> MQTTnet reports a broker rejection as a
|
||||
/// <i>result</i> (as it does for CONNACK — see <see cref="MqttConnectRejectedException"/>),
|
||||
/// so a caller that treated "did not throw" as "published" would silently drop a rebirth
|
||||
/// NCMD against a broker whose ACL denies the NCMD topic and then wait forever for the
|
||||
/// birth it never asked for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Publishing on a down session is refused up front</b> rather than left to MQTTnet's
|
||||
/// own exception, so the message names the driver and the broker. A rebirth request lost to
|
||||
/// a reconnect is not a fault: the reconnect path re-requests one itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="topic">The concrete topic to publish on.</param>
|
||||
/// <param name="payload">The message body.</param>
|
||||
/// <param name="qos">Requested QoS, 0–2; out-of-range values clamp.</param>
|
||||
/// <param name="retain">The MQTT <c>retain</c> flag.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation; linked with the publish deadline.</param>
|
||||
/// <returns>A task that completes when the broker has accepted the message.</returns>
|
||||
/// <exception cref="TimeoutException">The publish deadline elapsed.</exception>
|
||||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||||
/// <exception cref="ObjectDisposedException">The connection was disposed.</exception>
|
||||
/// <exception cref="InvalidOperationException">There is no established session, or the broker refused the message.</exception>
|
||||
public async Task PublishAsync(
|
||||
string topic,
|
||||
byte[] payload,
|
||||
int qos,
|
||||
bool retain,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(topic);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
|
||||
var client = _client;
|
||||
if (client is null || !client.IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': cannot publish '{topic}' — there is no established session to "
|
||||
+ $"{_options.Host}:{_options.Port}.");
|
||||
}
|
||||
|
||||
var message = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(payload)
|
||||
.WithQualityOfServiceLevel(MapQos(qos))
|
||||
.WithRetainFlag(retain)
|
||||
.Build();
|
||||
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
deadline.Token,
|
||||
_lifetimeCts.Token);
|
||||
|
||||
MqttClientPublishResult result;
|
||||
try
|
||||
{
|
||||
result = await client.PublishAsync(message, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw Classify(ex, cancellationToken, deadline, operation: "publish");
|
||||
}
|
||||
|
||||
if (result is not null && !result.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': broker at {_options.Host}:{_options.Port} refused PUBLISH "
|
||||
+ $"'{topic}': {result.ReasonCode}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps a configured QoS integer onto MQTTnet's enum, clamping an out-of-range value.</summary>
|
||||
private static MqttQualityOfServiceLevel MapQos(int qos) => qos switch
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user