Merge master (75b7effb) into remed/lane-G1
Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
|
||||
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
|
||||
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
|
||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</item>
|
||||
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
|
||||
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
|
||||
/// DWORDs), STRING and structure arrays have no array encoder.</item>
|
||||
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
|
||||
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
|
||||
/// on a plain <c><file><number>:<word></c> address of at most 256 elements (the PCCC
|
||||
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
|
||||
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
|
||||
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
|
||||
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
|
||||
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
|
||||
/// and the index read is not atomic with the write.</item>
|
||||
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
|
||||
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
|
||||
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
|
||||
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
|
||||
/// against the resolved symbol at write time, which no compose-time table can know.</item>
|
||||
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
|
||||
/// nodes stay read-only.</item>
|
||||
/// </list>
|
||||
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
|
||||
/// an integer <c>arrayLength >= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
|
||||
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
/// driver whatever the client sent and is not refused.</para>
|
||||
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
||||
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
||||
/// </summary>
|
||||
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
|
||||
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
|
||||
};
|
||||
|
||||
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
|
||||
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
|
||||
};
|
||||
|
||||
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
|
||||
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
|
||||
public const int AbLegacyMaxArrayElements = 256;
|
||||
|
||||
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
|
||||
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
|
||||
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
|
||||
// writes on the native transport alone.
|
||||
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
|
||||
["L"] = ["Long"],
|
||||
["F"] = ["Float"],
|
||||
};
|
||||
|
||||
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
|
||||
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
|
||||
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
|
||||
/// </summary>
|
||||
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
return S7ArrayTypes.Contains(native);
|
||||
}
|
||||
case DriverTypeNames.AbCip:
|
||||
return AbCipSupports(dataType, tagConfig);
|
||||
case DriverTypeNames.AbLegacy:
|
||||
return AbLegacySupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.TwinCAT:
|
||||
return TwinCATSupports(dataType, tagConfig);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
|
||||
private static bool AbCipSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|
||||
|| ReadArrayLength(root) is null
|
||||
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
|
||||
}
|
||||
|
||||
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
|
||||
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
|
||||
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
|
||||
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
|
||||
// bool, string and DateTime elements are refused.
|
||||
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|
||||
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|
||||
|| dataType is "Boolean" or "String" or "DateTime")
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
|
||||
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|
||||
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|
||||
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !AbLegacyIsNativePlc5(driverConfigs);
|
||||
}
|
||||
|
||||
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
|
||||
// index, no sub-element and no indirection, for the file letters that carry a file number.
|
||||
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
|
||||
{
|
||||
fileLetter = "";
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
var src = address.Trim();
|
||||
var colon = src.IndexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
var letterEnd = 0;
|
||||
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
|
||||
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
|
||||
var fileNumber = src[letterEnd..colon];
|
||||
var word = src[(colon + 1)..];
|
||||
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|
||||
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
|
||||
return false;
|
||||
fileLetter = src[..letterEnd];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
|
||||
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
|
||||
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
|
||||
// family (AbLegacyTransportResolver), which writes PLC-5.
|
||||
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
|
||||
{
|
||||
var native = false;
|
||||
var plc5 = false;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
{
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
|
||||
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
|
||||
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object) continue;
|
||||
foreach (var dp in device.EnumerateObject())
|
||||
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return native && plc5;
|
||||
}
|
||||
|
||||
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
|
||||
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
|
||||
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
|
||||
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
|
||||
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
|
||||
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
|
||||
private static bool TwinCATSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|
||||
|| ReadArrayLength(root) is null)
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
if (!TwinCATArrayTypes.Contains(native)) return false;
|
||||
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
|
||||
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
|
||||
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
|
||||
if (dataType == "String" && !isString) return false;
|
||||
if (dataType == "DateTime" && !isWString && !isDate) return false;
|
||||
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
|
||||
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
|
||||
}
|
||||
|
||||
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
|
||||
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
|
||||
private static bool HasTwinCATBitSuffix(string symbolPath)
|
||||
{
|
||||
var src = symbolPath.Trim();
|
||||
if (src.StartsWith('.')) src = src[1..];
|
||||
var depth = 0;
|
||||
var lastDot = -1;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
if (src[i] == '[') depth++;
|
||||
else if (src[i] == ']') depth--;
|
||||
else if (src[i] == '.' && depth == 0) lastDot = i;
|
||||
}
|
||||
if (lastDot < 0) return false;
|
||||
var last = src[(lastDot + 1)..];
|
||||
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
|
||||
}
|
||||
|
||||
private static bool HasNonBlankString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
|
||||
|
||||
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsStringValue(JsonElement el, string value) =>
|
||||
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
|
||||
@@ -149,6 +149,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on the rebirth request's wait, below the AdminUI's 20 s <c>BrowserSessionService.PerCallTimeout</c>
|
||||
/// (review minor 4): a configured window above it could never take effect there, and a rebirth aimed at an
|
||||
/// offline edge node must not hold the operator's button for the whole window.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan MaxRebirthWait = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>The quiet spell that ends a rebirth request's wait once its births have started arriving.</summary>
|
||||
private static readonly TimeSpan RebirthQuietInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <summary>Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker.</summary>
|
||||
private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
@@ -174,6 +184,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
private readonly ConcurrentQueue<string> _publishedTopics = new();
|
||||
|
||||
private int _disposed;
|
||||
private long _birthCount;
|
||||
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
|
||||
private int _nodeCount;
|
||||
private int _oversized;
|
||||
@@ -195,9 +206,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// <param name="client">The connected MQTT client; disconnected and disposed by <see cref="DisposeAsync"/>.</param>
|
||||
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
|
||||
/// <param name="nodeCap">Cap on recorded nodes; defaults to <see cref="DefaultNodeCap"/>.</param>
|
||||
internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap)
|
||||
/// <param name="birthWindow">
|
||||
/// The Sparkplug birth-observation window: how long <see cref="RequestRebirthAsync"/> waits for the
|
||||
/// births it asked for. <see cref="TimeSpan.Zero"/> (the test default) returns as soon as the NCMDs
|
||||
/// are published.
|
||||
/// </param>
|
||||
internal MqttBrowseSession(
|
||||
MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap, TimeSpan birthWindow = default)
|
||||
{
|
||||
Mode = mode;
|
||||
BirthWindow = birthWindow;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_nodeCap = nodeCap;
|
||||
@@ -211,6 +229,59 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
|
||||
|
||||
/// <summary>The Sparkplug birth-observation window this session was opened with (R06:M-4).</summary>
|
||||
internal TimeSpan BirthWindow { get; }
|
||||
|
||||
/// <summary>How long a rebirth request waits for its births: the window, capped at <see cref="MaxRebirthWait"/>.</summary>
|
||||
internal TimeSpan RebirthWait => BirthWindow < MaxRebirthWait ? BirthWindow : MaxRebirthWait;
|
||||
|
||||
/// <summary>How many NBIRTH/DBIRTH messages this session has observed — the settle loop's signal.</summary>
|
||||
internal long BirthCount => Interlocked.Read(ref _birthCount);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until birth traffic has gone quiet for one <paramref name="quiet"/> interval, bounded by
|
||||
/// <paramref name="window"/> — the <c>UntilStable</c> cadence of the birth-observation window.
|
||||
/// </summary>
|
||||
/// <param name="birthCount">Reads the running birth count.</param>
|
||||
/// <param name="baseline">The count before the wait began.</param>
|
||||
/// <param name="awaitFirstBirth">
|
||||
/// When true, quiet only counts once at least one birth has arrived past <paramref name="baseline"/>
|
||||
/// — the shape a rebirth request needs, since its answer takes a broker round-trip to arrive.
|
||||
/// </param>
|
||||
/// <param name="quiet">The quiet spell that ends the wait.</param>
|
||||
/// <param name="window">The hard ceiling; non-positive returns at once.</param>
|
||||
/// <param name="cancellationToken">Cancels the wait.</param>
|
||||
/// <returns>A task that completes when the births have settled or the window has elapsed.</returns>
|
||||
internal static async Task SettleBirthsAsync(
|
||||
Func<long> birthCount,
|
||||
long baseline,
|
||||
bool awaitFirstBirth,
|
||||
TimeSpan quiet,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (window <= TimeSpan.Zero) return;
|
||||
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
var last = baseline;
|
||||
var sawBirth = !awaitFirstBirth;
|
||||
while (clock.Elapsed < window)
|
||||
{
|
||||
var step = window - clock.Elapsed;
|
||||
await Task.Delay(step < quiet ? step : quiet, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = birthCount();
|
||||
if (now != last)
|
||||
{
|
||||
last = now;
|
||||
sawBirth = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sawBirth) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The topics this session published, in order — the discriminating view of the above.</summary>
|
||||
internal IReadOnlyList<string> PublishedTopicsForTest => [.. _publishedTopics];
|
||||
|
||||
@@ -380,16 +451,25 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
var mapped = metric.DataType?.ToDriverDataType();
|
||||
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
|
||||
|
||||
var isArray = metric.DataType?.IsSparkplugArray() ?? false;
|
||||
|
||||
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
|
||||
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
|
||||
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
|
||||
else if (isArray) descriptor.Append(" · array metrics are not read in v1 — not bindable");
|
||||
|
||||
// Unbindable = the ingestor refuses it: an unmapped type, or ANY array variant (SparkplugIngestor
|
||||
// gates on the array bit and never feeds the tag). Such a leaf states NO address, so the
|
||||
// browse-commit refuses it at commit instead of writing a tag that sits at
|
||||
// BadWaitingForInitialData forever (R06:M-5). Labelling it alone was not enough — it still bound.
|
||||
var bindable = mapped is not null && !isArray;
|
||||
|
||||
return new AttributeInfo(
|
||||
metric.Name,
|
||||
mapped?.ToString() ?? sparkplugName,
|
||||
metric.DataType?.IsSparkplugArray() ?? false,
|
||||
isArray,
|
||||
descriptor.ToString(),
|
||||
AddressFields: BuildMetricAddressFields(metric.Name, scope));
|
||||
AddressFields: bindable ? BuildMetricAddressFields(metric.Name, scope) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -557,6 +637,8 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
if (!SparkplugTopic.TryParse(topic, out var parsed)) return;
|
||||
if (parsed.Type is not (SparkplugMessageType.NBIRTH or SparkplugMessageType.DBIRTH)) return;
|
||||
|
||||
Interlocked.Increment(ref _birthCount);
|
||||
|
||||
// The bound has to be on the whole message: unlike a plain payload, a birth must be parsed
|
||||
// entire to yield its metric list, and the parse runs on the shared dispatcher thread.
|
||||
if (payload.Length > MaxBirthPayloadBytes)
|
||||
@@ -898,6 +980,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
|
||||
|
||||
var targets = ResolveRebirthTargets(scope);
|
||||
var baseline = BirthCount;
|
||||
|
||||
foreach (var (groupId, edgeNodeId) in targets)
|
||||
{
|
||||
@@ -908,6 +991,20 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// The birth-observation window (R06:M-4): wait — bounded — for the births just asked for to arrive
|
||||
// and settle, so the tree the picker re-renders after this call already carries them. A courtesy,
|
||||
// never a failure: the NCMDs are out, so a caller deadline here still reports the publish.
|
||||
try
|
||||
{
|
||||
await SettleBirthsAsync(
|
||||
() => BirthCount, baseline, awaitFirstBirth: true, RebirthQuietInterval, RebirthWait, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Published; the caller simply stopped waiting for the answer.
|
||||
}
|
||||
|
||||
Touch();
|
||||
return targets.Count;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
|
||||
internal const int MaxOpenBudgetSeconds = 30;
|
||||
|
||||
/// <summary>Ceiling on the Sparkplug birth-observation window — an authored hour must not park a picker.</summary>
|
||||
internal const int MaxBirthWindowSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// How long the open waits for a quiet spell in birth traffic before handing the session back. An
|
||||
/// idle plant (births are never retained) costs exactly this, once.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan BirthQuietInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
|
||||
internal const string BrowseClientIdPrefix = "-browse-";
|
||||
|
||||
@@ -77,7 +86,8 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
openCts.CancelAfter(OpenBudget(opts));
|
||||
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
var session = new MqttBrowseSession(opts.Mode, client, _logger);
|
||||
var birthWindow = opts.Mode == MqttMode.SparkplugB ? BirthWindow(opts) : TimeSpan.Zero;
|
||||
var session = new MqttBrowseSession(opts.Mode, client, _logger, birthWindow: birthWindow);
|
||||
try
|
||||
{
|
||||
// Runs on MQTTnet's dispatcher: record and return, nothing else.
|
||||
@@ -100,6 +110,29 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
.Build();
|
||||
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (opts.Mode == MqttMode.SparkplugB)
|
||||
{
|
||||
// The birth-observation window (R06:M-4): births already in flight — an edge node mid-rebirth,
|
||||
// a primary-host flap — are collected until the traffic goes quiet, bounded by the window, so
|
||||
// the first RootAsync is not a snapshot taken in the middle of a burst. Passive: nothing is
|
||||
// published to provoke a birth. An idle plant costs one quiet interval.
|
||||
try
|
||||
{
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => session.BirthCount,
|
||||
baseline: session.BirthCount,
|
||||
awaitFirstBirth: false,
|
||||
BirthQuietInterval,
|
||||
birthWindow,
|
||||
openCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The open budget ran out mid-settle: what has been observed is still a usable session.
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
|
||||
+ "'{Filter}' (read-only).",
|
||||
@@ -179,12 +212,30 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
options with { CleanSession = true };
|
||||
|
||||
/// <summary>
|
||||
/// Whole-open bound (connect + subscribe), clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's
|
||||
/// own connect deadline.
|
||||
/// Whole-open bound: connect + subscribe, clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's own
|
||||
/// connect deadline — plus, in Sparkplug mode, the <see cref="BirthWindow"/> the open may spend
|
||||
/// collecting births.
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
/// <returns>The open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options)
|
||||
{
|
||||
var connect = TimeSpan.FromSeconds(
|
||||
Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
return options.Mode == MqttMode.SparkplugB ? connect + BirthWindow(options) : connect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored <c>sparkplug.birthObservationWindowSeconds</c>, clamped to 1–<see cref="MaxBirthWindowSeconds"/>
|
||||
/// s: how long the browse surface collects NBIRTH/DBIRTH traffic before calling the observed metric set
|
||||
/// stable (on open, and after an operator rebirth request).
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped window.</returns>
|
||||
internal static TimeSpan BirthWindow(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(
|
||||
options.Sparkplug?.BirthObservationWindowSeconds ?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
|
||||
1,
|
||||
MaxBirthWindowSeconds));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,30 @@ public sealed class LastValueCache
|
||||
_values[rawPath] = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>How many RawPaths currently hold an entry. Diagnostics and the bounded-growth tests.</summary>
|
||||
internal int Count => _values.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Drops every entry whose RawPath <paramref name="keep"/> rejects — called by each ingest path's
|
||||
/// <c>Register</c> with "is this still an authored tag", so the cache is bounded by the CURRENT
|
||||
/// authored set rather than by the union of every RawPath (and every non-authored reference ever
|
||||
/// subscribed) across the driver's lifetime (R06:M-2).
|
||||
/// </summary>
|
||||
/// <param name="keep">Returns <see langword="true"/> for a RawPath whose entry must survive.</param>
|
||||
/// <returns>How many entries were dropped.</returns>
|
||||
public int RetainOnly(Func<string, bool> keep)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keep);
|
||||
var dropped = 0;
|
||||
foreach (var rawPath in _values.Keys)
|
||||
{
|
||||
if (!keep(rawPath) && _values.TryRemove(rawPath, out _))
|
||||
dropped++;
|
||||
}
|
||||
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the last observed value for <paramref name="rawPath"/>. Never throws: a
|
||||
/// <c>null</c>/empty <paramref name="rawPath"/> or a RawPath never observed both return a
|
||||
|
||||
@@ -104,6 +104,12 @@ public sealed class MqttDriver
|
||||
/// </summary>
|
||||
private const string DiscoveryFolderName = "Mqtt";
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on PUBLISHes one write batch keeps in flight (R06:M-8). Bounded so a large batch cannot
|
||||
/// flood a broker's inflight window; different topics share it, one topic's writes stay sequential.
|
||||
/// </summary>
|
||||
internal const int MaxConcurrentPublishes = 8;
|
||||
|
||||
/// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
|
||||
private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
@@ -208,6 +214,18 @@ public sealed class MqttDriver
|
||||
private SparkplugPrimaryHost? _primaryHost;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>UTC ticks of the last successful write PUBLISH; 0 = none. Answer evidence for health.</summary>
|
||||
private long _lastPublishUtcTicks;
|
||||
|
||||
/// <summary>Guards the generation bump against the generation-checked health write.</summary>
|
||||
private readonly object _healthLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bumped by every lifecycle operation (initialize inside the gate; shutdown and dispose before
|
||||
/// waiting for it). The stamp that keeps a superseded operation from publishing state.
|
||||
/// </summary>
|
||||
private long _lifecycleGeneration;
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private int _disposed;
|
||||
@@ -289,6 +307,9 @@ public sealed class MqttDriver
|
||||
/// </summary>
|
||||
internal Func<CancellationToken, Task>? BeforeConnectHookForTests { get; set; }
|
||||
|
||||
/// <summary>Test seam: observes every <see cref="MqttConnection"/> an initialize builds. Null in production.</summary>
|
||||
internal Action<MqttConnection>? ConnectionCreatedForTests { get; set; }
|
||||
|
||||
// ---- IDriver: lifecycle ----
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -387,6 +408,11 @@ public sealed class MqttDriver
|
||||
// watchdog's reconnect) is measured against, reporting a driver that was delivering a moment ago
|
||||
// as one that has never delivered.
|
||||
var lastMessage = GetHealth().LastSuccessfulRead;
|
||||
|
||||
// Taken BEFORE the gate wait: a wedged initialize holding the gate is superseded by this call, so
|
||||
// when it finally completes it disposes the session it built instead of publishing it behind the
|
||||
// ungated teardown below (R06:M-10), and its late health write is refused.
|
||||
var generation = BeginLifecycleOperation();
|
||||
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
@@ -408,8 +434,10 @@ public sealed class MqttDriver
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null));
|
||||
TransitionHost(HostState.Unknown);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastMessage, null)))
|
||||
{
|
||||
TransitionHost(HostState.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -427,7 +455,7 @@ public sealed class MqttDriver
|
||||
return stored;
|
||||
}
|
||||
|
||||
var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead;
|
||||
var lastMessage = Latest(connection.LastMessageUtc ?? stored.LastSuccessfulRead, LastPublishUtc);
|
||||
return connection.State switch
|
||||
{
|
||||
MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError),
|
||||
@@ -689,9 +717,12 @@ public sealed class MqttDriver
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
|
||||
var results = new WriteResult[writes.Count];
|
||||
var results = new WriteResult?[writes.Count];
|
||||
var connection = _connection;
|
||||
|
||||
// Pass 1 — everything that needs no broker: resolve, encode, refuse. What survives is grouped by
|
||||
// destination topic in REQUEST order.
|
||||
var byTopic = new Dictionary<string, List<(int Index, MqttEncodedWrite Encoded, string Reference)>>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
@@ -719,22 +750,72 @@ public sealed class MqttDriver
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byTopic.TryGetValue(encoded.Topic, out var queue))
|
||||
{
|
||||
byTopic[encoded.Topic] = queue = [];
|
||||
}
|
||||
|
||||
queue.Add((i, encoded, w.FullReference));
|
||||
}
|
||||
|
||||
// Pass 2 — publish (R06:M-8). Different topics go out concurrently, at most MaxConcurrentPublishes
|
||||
// in flight, so a stalled broker costs a batch ~one publish deadline instead of one per request.
|
||||
// Writes to the SAME topic stay strictly sequential in request order: the device applies them in
|
||||
// arrival order, and the last one must win.
|
||||
if (byTopic.Count > 0)
|
||||
{
|
||||
using var slots = new SemaphoreSlim(MaxConcurrentPublishes, MaxConcurrentPublishes);
|
||||
await Task.WhenAll(byTopic.Values.Select(queue => PublishQueueAsync(connection!, queue, slots, results, cancellationToken)))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Every slot is filled — a caller cancellation leaves the unattempted ones BadCommunicationError
|
||||
// rather than throwing, so the batch shape holds.
|
||||
var filled = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < filled.Length; i++)
|
||||
{
|
||||
filled[i] = results[i] ?? new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
/// <summary>Publishes one topic's writes in request order, each under a shared concurrency slot.</summary>
|
||||
private async Task PublishQueueAsync(
|
||||
MqttConnection connection,
|
||||
List<(int Index, MqttEncodedWrite Encoded, string Reference)> queue,
|
||||
SemaphoreSlim slots,
|
||||
WriteResult?[] results,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (index, encoded, reference) in queue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // caller-cancelled: the remaining slots are filled by the caller
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await connection
|
||||
.PublishAsync(encoded.Topic, encoded.Payload, encoded.Qos, retain: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
results[index] = new WriteResult(0u);
|
||||
|
||||
// A PUBACK is the broker answering — evidence the session works, so it feeds
|
||||
// LastSuccessfulRead alongside inbound messages. A completed QoS 0 publish is NOT: it only
|
||||
// means the bytes reached the local socket buffer, and frequent Sparkplug commands would
|
||||
// otherwise keep the watchdog's clock fresh over a half-open session (review minor 2).
|
||||
if (encoded.Qos >= 1) Interlocked.Exchange(ref _lastPublishUtcTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
|
||||
for (var j = i; j < writes.Count; j++)
|
||||
{
|
||||
results[j] = new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
|
||||
return results;
|
||||
results[index] = new WriteResult(StatusBadCommunicationError);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -742,13 +823,15 @@ public sealed class MqttDriver
|
||||
ex,
|
||||
"MQTT driver '{DriverId}': write to '{Reference}' could not be published on '{Topic}'.",
|
||||
_driverInstanceId,
|
||||
w.FullReference,
|
||||
reference,
|
||||
encoded.Topic);
|
||||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||||
results[index] = new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Maps an encode refusal to the status an OPC UA client sees.</summary>
|
||||
@@ -911,6 +994,9 @@ public sealed class MqttDriver
|
||||
return;
|
||||
}
|
||||
|
||||
// Supersedes any in-flight lifecycle operation, exactly as ShutdownAsync does.
|
||||
BeginLifecycleOperation();
|
||||
|
||||
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds))
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
@@ -940,7 +1026,12 @@ public sealed class MqttDriver
|
||||
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
|
||||
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
// Taken INSIDE the gate (the caller holds it), so a second initialize queued behind this one does
|
||||
// not supersede it; only a ShutdownAsync / DisposeAsync — which take theirs before waiting — can.
|
||||
var generation = BeginLifecycleOperation();
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
MqttConnection? built = null;
|
||||
var published = false;
|
||||
try
|
||||
{
|
||||
// Tear down whatever this driver already holds BEFORE building a new session. Both fields
|
||||
@@ -965,6 +1056,8 @@ public sealed class MqttDriver
|
||||
RegisterAuthoredTags(_options);
|
||||
|
||||
var connection = new MqttConnection(_options, _driverInstanceId, _logger);
|
||||
built = connection;
|
||||
ConnectionCreatedForTests?.Invoke(connection);
|
||||
|
||||
// MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
|
||||
// The handler is passed through unwrapped on purpose — its throw-on-total-failure is what
|
||||
@@ -992,10 +1085,13 @@ public sealed class MqttDriver
|
||||
// disposing what we just built, never leaking it.
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw new ObjectDisposedException(nameof(MqttDriver));
|
||||
}
|
||||
|
||||
// The same race against a ShutdownAsync whose bounded gate wait expired: it has already torn
|
||||
// down ungated, so publishing now would leave a live session no later shutdown can see.
|
||||
ThrowIfSuperseded(generation);
|
||||
|
||||
// Sparkplug subscribes ONCE, here, rather than on the OPC UA server's first subscribe: the
|
||||
// driver must see birth certificates whether or not anything is subscribed yet, because a
|
||||
// birth is where every alias and datatype comes from. This also issues the late-join
|
||||
@@ -1008,28 +1104,47 @@ public sealed class MqttDriver
|
||||
// MqttConnection's own remarks describe having already fixed once.
|
||||
if (_sparkplug is { } sparkplug)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ThrowIfSuperseded(generation);
|
||||
_connection = connection;
|
||||
published = true;
|
||||
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null)))
|
||||
{
|
||||
TransitionHost(HostState.Running);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
|
||||
TransitionHost(HostState.Running);
|
||||
StartHostProbe();
|
||||
await StartPrimaryHostAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Re-checked after publishing: a shutdown whose gate wait expired between the check above and
|
||||
// here captured whatever it found and left these fields for us. Intended consequence (review
|
||||
// minor 8): a perfectly healthy init that is just completing when a ShutdownAsync arrives also
|
||||
// releases itself and throws OperationCanceledException — the queued shutdown would tear it down
|
||||
// anyway, and the actor serialises init/shutdown, so only an abandoned init can meet this. We still hold the gate, so no
|
||||
// newer initialize can have published anything — everything in them is ours to release.
|
||||
if (!IsCurrentLifecycle(generation))
|
||||
{
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
ThrowIfSuperseded(generation);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
|
||||
TransitionHost(HostState.Faulted);
|
||||
// R06:M-3: a connection built but never published is disposed here — a refused connect, a
|
||||
// failed Sparkplug establish, a supersede. The published one belongs to TeardownAsync.
|
||||
if (!published && built is not null)
|
||||
{
|
||||
await built.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)))
|
||||
{
|
||||
TransitionHost(HostState.Faulted);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -1380,24 +1495,28 @@ public sealed class MqttDriver
|
||||
/// <summary>Shared teardown for <see cref="ShutdownAsync"/>, <see cref="DisposeAsync"/> and the session rebuild.</summary>
|
||||
private async Task TeardownAsync()
|
||||
{
|
||||
// Every field is CAPTURED before anything is disposed. A shutdown that timed out on the gate runs
|
||||
// this ungated; capturing up front means it only ever releases what existed when it started, and
|
||||
// never reaches into fields a later initialize publishes while the slow disposals below run.
|
||||
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
|
||||
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
|
||||
var connection = Interlocked.Exchange(ref _connection, null);
|
||||
|
||||
// Stand the host claim down FIRST, while the broker is still reachable and before the ingest
|
||||
// session goes. Its explicit retained OFFLINE is what makes the transition immediate for edge
|
||||
// nodes; leaving it to the Will costs them up to 1.5 keep-alive periods of believing a host
|
||||
// that has already stopped is still online.
|
||||
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
|
||||
if (primaryHost is not null)
|
||||
{
|
||||
await primaryHost.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
|
||||
if (probe is not null)
|
||||
{
|
||||
await probe.CancelAsync().ConfigureAwait(false);
|
||||
probe.Dispose();
|
||||
}
|
||||
|
||||
var connection = Interlocked.Exchange(ref _connection, null);
|
||||
if (connection is not null)
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -1476,9 +1595,66 @@ public sealed class MqttDriver
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next));
|
||||
}
|
||||
|
||||
/// <summary>The last successful write publish, or null.</summary>
|
||||
private DateTime? LastPublishUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastPublishUtcTicks);
|
||||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The later of two optional instants.</summary>
|
||||
private static DateTime? Latest(DateTime? a, DateTime? b)
|
||||
=> a is null ? b : b is null ? a : (a > b ? a : b);
|
||||
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
/// <summary>
|
||||
/// Starts a lifecycle operation and returns its generation. Every later operation supersedes it:
|
||||
/// its health / host-state writes are refused and an initialize stops before publishing a session.
|
||||
/// </summary>
|
||||
private long BeginLifecycleOperation()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
return ++_lifecycleGeneration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
|
||||
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
|
||||
|
||||
/// <summary>Throws when a newer lifecycle operation has superseded <paramref name="generation"/>.</summary>
|
||||
private void ThrowIfSuperseded(long generation)
|
||||
{
|
||||
if (!IsCurrentLifecycle(generation))
|
||||
{
|
||||
throw new OperationCanceledException(
|
||||
$"MQTT driver '{_driverInstanceId}': this initialize was superseded by a later shutdown/dispose; "
|
||||
+ "the session it built has been released.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a health snapshot only while <paramref name="generation"/> is the newest lifecycle
|
||||
/// operation — the check and the write are atomic against <see cref="BeginLifecycleOperation"/>, so a
|
||||
/// superseded initialize or shutdown can never overwrite a newer one's state.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the write was applied.</returns>
|
||||
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
if (_lifecycleGeneration != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _health, value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// accepted by the parser (warned at deploy, see <c>MqttTagDefinitionFactory.Inspect</c>), so
|
||||
/// matching uses MQTTnet's own <see cref="MqttTopicFilterComparer"/> rather than string
|
||||
/// equality — the same comparer a broker applies. Concrete topics stay a single dictionary
|
||||
/// lookup; the linear wildcard scan runs only when a wildcard tag actually exists.
|
||||
/// lookup; wildcard filters live in a level trie (<c>TopicFilterTrie</c>) whose candidates the comparer
|
||||
/// confirms, so a thousand wildcard tags cost a walk over the topic's levels, not a thousand compares.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sparkplug seam.</b> P1 ingests plain topics only. Sparkplug B decodes a
|
||||
@@ -74,6 +75,13 @@ public sealed class MqttSubscriptionManager
|
||||
/// </summary>
|
||||
public const int DefaultQos = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Throw-on-invalid UTF-8, shared (R06:M-7): it is stateless and thread-safe, and allocating one per
|
||||
/// Raw/Scalar delivery was per-message garbage on the dispatcher thread. Same choice
|
||||
/// <c>MqttBrowseSession</c> makes.
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
|
||||
@@ -113,6 +121,14 @@ public sealed class MqttSubscriptionManager
|
||||
/// <summary>Topics already SUBSCRIBEd on the live session — the dedupe set and the re-subscribe set.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _subscribedTopics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Topics a subscribe asked for whose SUBSCRIBE failed (refused, or no verdict at all). Retried by
|
||||
/// every later <see cref="SubscribeAsync"/> and by <see cref="OnReconnectedAsync"/>; moved to
|
||||
/// <see cref="_subscribedTopics"/> when granted. Without it a failed topic left every retry set and
|
||||
/// stayed dark behind a Healthy connection until a redeploy.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _pendingTopics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>RawPaths whose extraction failure has already been logged loudly once.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -212,8 +228,8 @@ public sealed class MqttSubscriptionManager
|
||||
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
|
||||
/// both together. Call once per connection, and <b>before</b> the first
|
||||
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
|
||||
/// intent (and warns) but sends nothing to a broker, and would only be established if the
|
||||
/// connection later dropped and recovered.
|
||||
/// topics as <i>pending</i> (and warns) but sends nothing to a broker; the next subscribe or
|
||||
/// reconnect on an attached session retries them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Binding to a session forgets the previous session's established filters</b> — the same
|
||||
@@ -317,6 +333,11 @@ public sealed class MqttSubscriptionManager
|
||||
_authored = table;
|
||||
_warnedRefs.Clear();
|
||||
|
||||
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
|
||||
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
|
||||
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
|
||||
Values.RetainOnly(table.ByRawPath.ContainsKey);
|
||||
|
||||
// Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was
|
||||
// deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect,
|
||||
// forever — and its RawPaths keep a handle mapping that can never fire again.
|
||||
@@ -328,6 +349,14 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var topic in _pendingTopics.Keys)
|
||||
{
|
||||
if (!table.ByFilter.ContainsKey(topic))
|
||||
{
|
||||
_pendingTopics.TryRemove(topic, out _);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var rawPath in _handleByRawPath.Keys)
|
||||
{
|
||||
if (!table.ByRawPath.ContainsKey(rawPath))
|
||||
@@ -356,12 +385,13 @@ public sealed class MqttSubscriptionManager
|
||||
/// opening SUBSCRIBE itself; <see cref="OnReconnectedAsync"/> only re-establishes it later.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Never throws, never hangs.</b> A reference that is not an authored tag is recorded as
|
||||
/// <c>BadNodeIdUnknown</c>; a SUBACK rejection, transport failure or subscribe deadline
|
||||
/// degrades exactly the affected references to <c>BadCommunicationError</c>. Either way a
|
||||
/// handle is returned — the OPC UA server's subscribe call must not fail because one topic
|
||||
/// is ACL-denied. Recovery for a transient failure comes from the reconnect path;
|
||||
/// a persistent broker-side refusal is visible as a Bad status on the affected nodes.
|
||||
/// <b>Never hangs; throws only when the broker gave no verdict.</b> A reference that is not an
|
||||
/// authored tag is recorded as <c>BadNodeIdUnknown</c>. A SUBACK rejection degrades exactly the
|
||||
/// affected references to <c>BadCommunicationError</c> and still returns a handle — the OPC UA
|
||||
/// server's subscribe must not fail because one topic is ACL-denied. A transport failure or
|
||||
/// subscribe deadline degrades the same way and then <b>throws</b>, so the host holds no handle
|
||||
/// and its reconcile re-subscribes. Every failed topic also joins a pending set that the next
|
||||
/// subscribe and every reconnect retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="fullReferences">The RawPath references to subscribe.</param>
|
||||
@@ -414,12 +444,56 @@ public sealed class MqttSubscriptionManager
|
||||
|
||||
// One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an
|
||||
// established filter is legal but pointless, and it re-triggers the retained seed.
|
||||
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList();
|
||||
await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
|
||||
// Topics an EARLIER subscribe failed to establish ride along: a redeploy's bulk re-subscribe is then a
|
||||
// retry of a refusal the broker may since have lifted, instead of a no-op that leaves them dark.
|
||||
var authored = _authored;
|
||||
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic))
|
||||
.Concat(PendingDefinitions(authored))
|
||||
.ToList();
|
||||
var outcome = await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (outcome.TransportFailure is { } failure)
|
||||
{
|
||||
// The refs are already Bad and pending either way. But a cancel of the CALLER's token (a pipeline
|
||||
// timeout, host shutdown) is a cancellation, not a broker fault: surfacing it as
|
||||
// InvalidOperationException made the resilience breaker count it as a failure (review minor 3).
|
||||
var callerCancelled = cancellationToken.IsCancellationRequested;
|
||||
// No verdict at all (transport throw / SUBSCRIBE deadline): THROW, so the caller holds no handle
|
||||
// and DriverInstanceActor's health-tick reconcile re-subscribes. Returning a handle here was the
|
||||
// silent-death case — the actor thought the subscription live and never retried. The refs already
|
||||
// carry BadCommunicationError (raised under this handle above), so the throw costs no visibility.
|
||||
_refsByHandle.TryRemove(handle, out _);
|
||||
foreach (var def in defs)
|
||||
{
|
||||
_handleByRawPath.TryRemove(new KeyValuePair<string, ISubscriptionHandle>(def.Name, handle));
|
||||
}
|
||||
|
||||
if (callerCancelled)
|
||||
{
|
||||
throw new OperationCanceledException(
|
||||
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) was cancelled by the caller; "
|
||||
+ "the topics stay pending and will be retried.",
|
||||
failure,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) got no broker verdict "
|
||||
+ $"({failure.GetType().Name}); the subscription will be retried.",
|
||||
failure);
|
||||
}
|
||||
|
||||
// A SUBACK refusal (an ACL denial, say) does NOT throw: the broker answered, the refused topics are
|
||||
// Bad + pending, and failing the whole subscribe would take every other topic in it dark too.
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>The authored definitions behind every still-authored pending topic.</summary>
|
||||
private IEnumerable<MqttTagDefinition> PendingDefinitions(AuthoredTable authored)
|
||||
=> _pendingTopics.Keys
|
||||
.Select(topic => authored.ByFilter.TryGetValue(topic, out var defs) ? defs : [])
|
||||
.SelectMany(defs => defs);
|
||||
|
||||
/// <summary>
|
||||
/// Stops attributing notifications to <paramref name="handle"/>. References another live
|
||||
/// subscription also covers keep publishing.
|
||||
@@ -498,8 +572,13 @@ public sealed class MqttSubscriptionManager
|
||||
.SelectMany(defs => defs)
|
||||
.ToList();
|
||||
|
||||
var filters = BuildFilters(live, _defaultQos);
|
||||
if (filters.Count == 0)
|
||||
// Pending topics (a failed earlier SUBSCRIBE) are retried here too — a reconnect re-subscribing
|
||||
// only the granted set is how a failed topic stayed dark forever. They do NOT count toward the
|
||||
// total-failure verdict below: re-refusing a topic that was never live loses nothing, and throwing
|
||||
// for it would turn one ACL-denied topic into a reconnect loop for the whole driver.
|
||||
var pendingDefs = PendingDefinitions(authored).ToList();
|
||||
var filters = BuildFilters(live.Concat(pendingDefs), _defaultQos);
|
||||
if (live.Count == 0 && pendingDefs.Count == 0)
|
||||
{
|
||||
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. Live
|
||||
// topics resolving to zero filters re-establishes nothing, which is the total-failure case
|
||||
@@ -519,13 +598,17 @@ public sealed class MqttSubscriptionManager
|
||||
return;
|
||||
}
|
||||
|
||||
var granted = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
|
||||
if (granted == 0)
|
||||
var outcome = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
|
||||
var liveTopics = live.Select(d => d.Topic).Distinct(StringComparer.Ordinal).ToList();
|
||||
var liveLost = outcome.TransportFailure is not null
|
||||
|| (subscribed.Count > 0 && (liveTopics.Count == 0 || !liveTopics.Any(outcome.Granted.Contains)));
|
||||
if (liveLost)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': no subscription could be re-established after reconnect "
|
||||
+ $"({filters.Count} filter(s) requested); tearing the session down rather than serving a "
|
||||
+ "connection that receives nothing.");
|
||||
+ "connection that receives nothing.",
|
||||
outcome.TransportFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,13 +665,14 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
|
||||
// A published topic name may not itself contain a wildcard, so a wildcard-authored
|
||||
// definition can never also be an exact-index hit: no tag is delivered twice.
|
||||
foreach (var def in authored.Wildcards)
|
||||
// definition can never also be an exact-index hit: no tag is delivered twice. The trie walks
|
||||
// the topic's levels (R06:M-6 — this used to be one comparer call per wildcard tag, per
|
||||
// message, on the shared dispatcher thread) and every candidate it yields is confirmed by
|
||||
// MQTTnet's own comparer inside the visitor, so the semantics are the broker's.
|
||||
if (authored.WildcardTrie.Count > 0)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
Deliver(def, payload, retained, now, oversized, ref json);
|
||||
}
|
||||
var visitor = new WildcardDelivery(this, topic, payload, retained, now, oversized, ref json);
|
||||
authored.WildcardTrie.Match(topic, ref visitor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -633,35 +717,35 @@ public sealed class MqttSubscriptionManager
|
||||
/// </summary>
|
||||
/// <param name="filters">The filters to establish; an empty list is a no-op.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the round-trip.</param>
|
||||
/// <returns>How many filters the broker granted.</returns>
|
||||
private async Task<int> EstablishAsync(
|
||||
/// <returns>The granted topics, plus the transport failure when the broker gave no verdict at all.</returns>
|
||||
private async Task<EstablishOutcome> EstablishAsync(
|
||||
IReadOnlyList<MqttTopicSubscription> filters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (filters.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
return EstablishOutcome.Nothing;
|
||||
}
|
||||
|
||||
var transport = _transport;
|
||||
if (transport is null)
|
||||
{
|
||||
// No transport: the intent is recorded so the reconnect path establishes it, but NOTHING
|
||||
// reaches a broker now. Loud, because outside tests this means the composition order is
|
||||
// wrong — AttachTo must run before the OPC UA server's first subscribe, or these topics
|
||||
// stay unestablished until the connection happens to drop and recover.
|
||||
// No transport: NOTHING reaches a broker, so nothing is recorded as subscribed — recording it
|
||||
// was a silent "subscribed, receives nothing" if the AttachTo-before-subscribe ordering ever
|
||||
// changed. The topics wait in the pending set, which the next subscribe and every reconnect
|
||||
// retry. Loud, because outside tests this means the composition order is wrong.
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
_subscribedTopics[filter.Topic] = 0;
|
||||
_pendingTopics[filter.Topic] = 0;
|
||||
}
|
||||
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': {FilterCount} subscription(s) recorded with no connection attached; "
|
||||
+ "nothing was sent to a broker. Call AttachTo before subscribing.",
|
||||
"MQTT driver '{DriverId}': {FilterCount} subscription(s) requested with no connection attached; "
|
||||
+ "nothing was sent to a broker and they stay pending. Call AttachTo before subscribing.",
|
||||
_driverId,
|
||||
filters.Count);
|
||||
|
||||
return filters.Count;
|
||||
return EstablishOutcome.Nothing;
|
||||
}
|
||||
|
||||
IReadOnlyList<MqttSubscribeOutcome> outcomes;
|
||||
@@ -681,16 +765,17 @@ public sealed class MqttSubscriptionManager
|
||||
DegradeTopic(filter.Topic, ex.GetType().Name);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return new EstablishOutcome(new HashSet<string>(StringComparer.Ordinal), ex);
|
||||
}
|
||||
|
||||
var granted = 0;
|
||||
var granted = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
if (outcome.Granted)
|
||||
{
|
||||
_subscribedTopics[outcome.Topic] = 0;
|
||||
granted++;
|
||||
_pendingTopics.TryRemove(outcome.Topic, out _);
|
||||
granted.Add(outcome.Topic);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -703,7 +788,16 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
return new EstablishOutcome(granted, null);
|
||||
}
|
||||
|
||||
/// <summary>One SUBSCRIBE round-trip's verdict.</summary>
|
||||
/// <param name="Granted">The filters the broker granted.</param>
|
||||
/// <param name="TransportFailure">Set when the call produced no verdict at all (throw / deadline).</param>
|
||||
private sealed record EstablishOutcome(IReadOnlySet<string> Granted, Exception? TransportFailure)
|
||||
{
|
||||
public static readonly EstablishOutcome Nothing =
|
||||
new(new HashSet<string>(StringComparer.Ordinal), null);
|
||||
}
|
||||
|
||||
/// <summary>Marks every authored reference on <paramref name="topic"/> as unreachable.</summary>
|
||||
@@ -722,6 +816,7 @@ public sealed class MqttSubscriptionManager
|
||||
|
||||
if (!_authored.ByFilter.TryGetValue(topic, out var defs))
|
||||
{
|
||||
_pendingTopics.TryRemove(topic, out _);
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': cannot degrade tags for unestablished filter '{Topic}' ({Reason}) — "
|
||||
+ "no authored tag resolves to it.",
|
||||
@@ -731,6 +826,9 @@ public sealed class MqttSubscriptionManager
|
||||
return;
|
||||
}
|
||||
|
||||
// Still authored, so it must be retried — see _pendingTopics.
|
||||
_pendingTopics[topic] = 0;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
foreach (var def in defs)
|
||||
@@ -1371,8 +1469,7 @@ public sealed class MqttSubscriptionManager
|
||||
{
|
||||
try
|
||||
{
|
||||
text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
|
||||
.GetString(payload);
|
||||
text = Utf8Strict.GetString(payload);
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
@@ -1400,6 +1497,47 @@ public sealed class MqttSubscriptionManager
|
||||
return $"mqtt:{_mode}:{id}:[{named}{overflow}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers each wildcard-trie candidate whose filter the MQTTnet comparer confirms. A <c>ref struct</c>
|
||||
/// so the payload span and the fan-out's shared JSON parse ride along without a closure or a list.
|
||||
/// </summary>
|
||||
private ref struct WildcardDelivery : ITopicFilterVisitor<MqttTagDefinition>
|
||||
{
|
||||
private readonly MqttSubscriptionManager _manager;
|
||||
private readonly string _topic;
|
||||
private readonly ReadOnlySpan<byte> _payload;
|
||||
private readonly bool _retained;
|
||||
private readonly DateTime _now;
|
||||
private readonly bool _oversized;
|
||||
private ref PayloadJson _json;
|
||||
|
||||
public WildcardDelivery(
|
||||
MqttSubscriptionManager manager,
|
||||
string topic,
|
||||
ReadOnlySpan<byte> payload,
|
||||
bool retained,
|
||||
DateTime now,
|
||||
bool oversized,
|
||||
ref PayloadJson json)
|
||||
{
|
||||
_manager = manager;
|
||||
_topic = topic;
|
||||
_payload = payload;
|
||||
_retained = retained;
|
||||
_now = now;
|
||||
_oversized = oversized;
|
||||
_json = ref json;
|
||||
}
|
||||
|
||||
public void Visit(MqttTagDefinition def)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(_topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
_manager.Deliver(def, _payload, _retained, _now, _oversized, ref _json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opaque subscription identity; the diagnostic id names the mode and its topics.</summary>
|
||||
/// <param name="DiagnosticId">The human-readable identity surfaced for diagnostics.</param>
|
||||
private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||
@@ -1411,7 +1549,8 @@ public sealed class MqttSubscriptionManager
|
||||
/// <remarks>
|
||||
/// <b>Two topic indexes, and the difference is load-bearing.</b> Delivery matches an
|
||||
/// <i>incoming published topic</i>, which never carries a wildcard, so it uses
|
||||
/// <see cref="ByExactTopic"/> (concrete only) plus a comparer scan of <see cref="Wildcards"/>.
|
||||
/// <see cref="ByExactTopic"/> (concrete only) plus a <see cref="WildcardTrie"/> walk whose candidates the
|
||||
/// comparer confirms.
|
||||
/// Every path that instead reconstructs state from a <i>subscribed filter string</i> —
|
||||
/// reconnect re-subscribe and per-topic degradation — must use <see cref="ByFilter"/>, which is
|
||||
/// keyed by the tag's authored topic <b>including wildcard patterns</b>, because that is what
|
||||
@@ -1425,12 +1564,12 @@ public sealed class MqttSubscriptionManager
|
||||
/// included. Keyed the same way <c>_subscribedTopics</c> and SUBACK outcomes are.
|
||||
/// </param>
|
||||
/// <param name="ByExactTopic">Concrete topic → its definitions; the delivery fast path.</param>
|
||||
/// <param name="Wildcards">Definitions whose authored topic carries an MQTT wildcard.</param>
|
||||
/// <param name="WildcardTrie">Definitions whose authored topic carries an MQTT wildcard, by filter level.</param>
|
||||
private sealed record AuthoredTable(
|
||||
FrozenDictionary<string, MqttTagDefinition> ByRawPath,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByFilter,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByExactTopic,
|
||||
MqttTagDefinition[] Wildcards)
|
||||
TopicFilterTrie<MqttTagDefinition> WildcardTrie)
|
||||
{
|
||||
public static readonly AuthoredTable Empty =
|
||||
Build(new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal));
|
||||
@@ -1480,7 +1619,7 @@ public sealed class MqttSubscriptionManager
|
||||
byRawPath.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
byFilter.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
|
||||
concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
|
||||
[.. wildcards]);
|
||||
TopicFilterTrie<MqttTagDefinition>.Build(wildcards.Select(d => (d.Topic, d))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +416,11 @@ public sealed class SparkplugIngestor
|
||||
_authored = table;
|
||||
_warned.Clear();
|
||||
|
||||
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
|
||||
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
|
||||
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
|
||||
Values.RetainOnly(table.ByRawPath.ContainsKey);
|
||||
|
||||
// Drop state belonging to edge nodes this deploy removed. Without this a tracker (and its
|
||||
// rebirth debounce slot) for a node nobody reads survives every future redeploy.
|
||||
foreach (var key in _trackers.Keys)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>Receives each candidate a <see cref="TopicFilterTrie{T}"/> match produces.</summary>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal interface ITopicFilterVisitor<in T>
|
||||
{
|
||||
/// <summary>Called once per value whose filter may match the topic.</summary>
|
||||
/// <param name="value">The candidate.</param>
|
||||
void Visit(T value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable trie over MQTT topic FILTERS (<c>+</c> / <c>#</c> wildcards), so matching one published
|
||||
/// topic costs a walk over its levels instead of a comparer call per wildcard-authored tag (R06:M-6:
|
||||
/// the linear scan ran on MQTTnet's shared dispatcher thread for every inbound message).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A candidate generator, not the authority.</b> The trie implements the plain MQTT 3.1.1 §4.7
|
||||
/// level rules — <c>+</c> is exactly one level (an empty one included), <c>#</c> is the parent level
|
||||
/// and everything under it — and deliberately nothing subtler (the <c>$</c>-topic exclusion, shared
|
||||
/// subscriptions). It can therefore only ever return a SUPERSET of the true matches, and every
|
||||
/// caller confirms each candidate with MQTTnet's own <c>MqttTopicFilterComparer</c>, the comparer a
|
||||
/// broker applies. Semantics stay byte-identical to the scan it replaces; only the work changes.
|
||||
/// A differential test pins both halves against that comparer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Allocation-free per match.</b> Levels are walked as spans and looked up through the
|
||||
/// dictionary's span alternate lookup; candidates are pushed to a caller-supplied visitor (a
|
||||
/// <c>ref struct</c> in production) rather than collected into a list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal sealed class TopicFilterTrie<T>
|
||||
{
|
||||
private readonly Node _root;
|
||||
|
||||
private TopicFilterTrie(Node root, int count)
|
||||
{
|
||||
_root = root;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
/// <summary>An empty trie.</summary>
|
||||
public static TopicFilterTrie<T> Empty { get; } = Build([]);
|
||||
|
||||
/// <summary>How many values were inserted.</summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>Builds a trie from <c>(filter, value)</c> pairs.</summary>
|
||||
/// <param name="entries">The filters and their values.</param>
|
||||
/// <returns>The immutable trie.</returns>
|
||||
public static TopicFilterTrie<T> Build(IEnumerable<(string Filter, T Value)> entries)
|
||||
{
|
||||
var root = new Builder();
|
||||
var count = 0;
|
||||
foreach (var (filter, value) in entries)
|
||||
{
|
||||
var node = root;
|
||||
var levels = filter.Split('/');
|
||||
var terminated = false;
|
||||
for (var i = 0; i < levels.Length; i++)
|
||||
{
|
||||
var level = levels[i];
|
||||
if (level == "#")
|
||||
{
|
||||
// '#' must be the last level; anything after it is a malformed filter the comparer
|
||||
// will refuse anyway, so treating '#' as terminal here can only over-generate.
|
||||
node.Hash.Add(value);
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
node = level == "+"
|
||||
? node.Plus ??= new Builder()
|
||||
: node.Children.TryGetValue(level, out var child) ? child : node.Children[level] = new Builder();
|
||||
}
|
||||
|
||||
if (!terminated) node.Exact.Add(value);
|
||||
count++;
|
||||
}
|
||||
|
||||
return new TopicFilterTrie<T>(root.Freeze(), count);
|
||||
}
|
||||
|
||||
/// <summary>Pushes every value whose filter may match <paramref name="topic"/> to <paramref name="visitor"/>.</summary>
|
||||
/// <typeparam name="TVisitor">The visitor; a <c>ref struct</c> is allowed.</typeparam>
|
||||
/// <param name="topic">A concrete published topic (never a filter).</param>
|
||||
/// <param name="visitor">Receives each candidate — a superset of the true matches; confirm each one.</param>
|
||||
public void Match<TVisitor>(string topic, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
if (Count == 0 || topic is null) return;
|
||||
Walk(_root, topic.AsSpan(), exhausted: false, ref visitor);
|
||||
}
|
||||
|
||||
private static void Walk<TVisitor>(Node node, ReadOnlySpan<char> rest, bool exhausted, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
// '#' here covers this level's parent-match AND every deeper level.
|
||||
foreach (var value in node.Hash) visitor.Visit(value);
|
||||
|
||||
if (exhausted)
|
||||
{
|
||||
foreach (var value in node.Exact) visitor.Visit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
var slash = rest.IndexOf('/');
|
||||
var level = slash < 0 ? rest : rest[..slash];
|
||||
var next = slash < 0 ? ReadOnlySpan<char>.Empty : rest[(slash + 1)..];
|
||||
var nextExhausted = slash < 0;
|
||||
|
||||
if (node.Children.Count > 0
|
||||
&& node.Children.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(level, out var child))
|
||||
{
|
||||
Walk(child, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
if (node.Plus is { } plus) Walk(plus, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
private sealed class Node(FrozenDictionary<string, Node> children, Node? plus, T[] exact, T[] hash)
|
||||
{
|
||||
public FrozenDictionary<string, Node> Children { get; } = children;
|
||||
|
||||
public Node? Plus { get; } = plus;
|
||||
|
||||
public T[] Exact { get; } = exact;
|
||||
|
||||
public T[] Hash { get; } = hash;
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
public Dictionary<string, Builder> Children { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Builder? Plus { get; set; }
|
||||
|
||||
public List<T> Exact { get; } = [];
|
||||
|
||||
public List<T> Hash { get; } = [];
|
||||
|
||||
public Node Freeze() => new(
|
||||
Children.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.Freeze(), StringComparer.Ordinal),
|
||||
Plus?.Freeze(),
|
||||
[.. Exact],
|
||||
[.. Hash]);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>Column alias carrying the SQL type family name.</summary>
|
||||
private const string DataTypeColumn = "DATA_TYPE";
|
||||
|
||||
/// <summary>
|
||||
/// Column alias carrying <c>YES</c>/<c>NO</c> nullability. Every shipped dialect projects it (Oracle
|
||||
/// normalises its <c>Y</c>/<c>N</c>); it used to be read by nothing (R06:S-5).
|
||||
/// </summary>
|
||||
private const string IsNullableColumn = "IS_NULLABLE";
|
||||
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
@@ -158,7 +164,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
|
||||
var timestampColumn = PreferredTimestampColumn(columns);
|
||||
var timestampColumn = PreferredTimestampColumn([.. columns.Select(c => (c.Name, c.DataType))]);
|
||||
|
||||
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
|
||||
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
|
||||
@@ -191,7 +197,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass,
|
||||
// ⚠ SecurityClass is (ab)used as the picker's descriptor line ("{type} · {SecurityClass}"),
|
||||
// exactly as the MQTT browser does. Every consumer today is display-only; a future one that
|
||||
// parses it as SecurityClassification must not read the Sql value (review m-6 — a dedicated
|
||||
// descriptor field on AttributeInfo is the Commons follow-up). Display only. Nullability is
|
||||
// what tells an operator a cell can come back NULL, which publishes Uncertain (or Bad under
|
||||
// nullIsBad), before they commit the tag.
|
||||
SecurityClass: column.IsNullable switch
|
||||
{
|
||||
true => ReadOnlySecurityClass + " · nullable",
|
||||
false => ReadOnlySecurityClass + " · NOT NULL",
|
||||
null => ReadOnlySecurityClass,
|
||||
},
|
||||
IsAlarm: false,
|
||||
AddressFields: addressFields),
|
||||
];
|
||||
@@ -356,7 +373,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
|
||||
/// relation, and neither is a reason to fail an operator's click.
|
||||
/// </summary>
|
||||
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
|
||||
private Task<List<(string Name, string DataType, bool? IsNullable)>> ReadColumnsAsync(
|
||||
string schema, string table, CancellationToken ct) =>
|
||||
QueryAsync(
|
||||
_dialect.ListColumnsSql,
|
||||
@@ -367,9 +384,30 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty,
|
||||
IsNullable: ReadNullability(reader)),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>IS_NULLABLE</c> as a tri-state: <see langword="null"/> when a (custom) dialect does not
|
||||
/// project it or the value is neither <c>YES</c> nor <c>NO</c> — "unknown" is said by omission, never
|
||||
/// guessed.
|
||||
/// </summary>
|
||||
private static bool? ReadNullability(DbDataReader reader)
|
||||
{
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
if (!string.Equals(reader.GetName(i), IsNullableColumn, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (reader.IsDBNull(i)) return null;
|
||||
var text = reader.GetValue(i)?.ToString()?.Trim();
|
||||
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
|
||||
|
||||
@@ -177,6 +177,31 @@ public interface ISqlDialect
|
||||
/// </summary>
|
||||
string SingleRowLimitSuffix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitPrefix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// T-SQL's <c>"TOP (n) "</c>; empty for a dialect that limits at the end. Same trailing-space rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Added for the key-value / where-pair row cap (R06:S-1): those plans used to carry no limit, so an
|
||||
/// ambiguous selector against a history table materialised every matching row on every poll. The
|
||||
/// planner asks for <c>N + 1</c> rows — one more than the plan can use — so ambiguity stays observable
|
||||
/// (the reader sees the extra row and publishes a configuration error) without unbounded reads.
|
||||
/// </remarks>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitPrefix(int rows);
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitSuffix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// <c>" LIMIT n"</c> / <c>" FETCH FIRST n ROWS ONLY"</c>; empty for a dialect that limits after
|
||||
/// <c>SELECT</c>. Same leading-space rule. See <see cref="RowLimitPrefix"/>.
|
||||
/// </summary>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitSuffix(int rows);
|
||||
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
|
||||
@@ -124,6 +124,21 @@ public sealed class OracleDialect : ISqlDialect
|
||||
// statement shape, which this seam deliberately leaves with the planner.
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
|
||||
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
|
||||
|
||||
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
//
|
||||
// Sourced from information_schema.tables, not information_schema.schemata - a deliberate
|
||||
|
||||
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
|
||||
// backwards-compatibility spelling.
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// manufacture an exception to force backoff.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -156,6 +156,16 @@ public sealed class SqlDriver
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Guards the lifecycle-generation bump against the generation-checked lifecycle health write.</summary>
|
||||
private readonly object _healthLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bumped by every <see cref="InitializeAsync"/> and <see cref="ShutdownAsync"/>. A lifecycle write from
|
||||
/// a superseded call, and a poll that STARTED under an older generation (a stale poll outliving the
|
||||
/// engine's 5 s teardown wait, R06:S-9), can no longer overwrite the newer state.
|
||||
/// </summary>
|
||||
private long _lifecycleGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// The exception instance <see cref="ReadAsync"/> has already turned into a health verdict, held only
|
||||
/// until <see cref="HandlePollError"/> — which the poll engine calls with that same object — has
|
||||
@@ -173,6 +183,9 @@ public sealed class SqlDriver
|
||||
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Occurs once per poll cycle that produced at least one change, carrying every change of that cycle.</summary>
|
||||
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
|
||||
|
||||
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -230,6 +243,9 @@ public sealed class SqlDriver
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap,
|
||||
// R02:P1 — one batch per poll burst, so DriverInstanceActor forwards one message per cycle instead
|
||||
// of one per tag (it subscribes exactly one of the two events).
|
||||
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
|
||||
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
|
||||
// closure IS the production consumer of its overrun signal.
|
||||
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
|
||||
@@ -261,6 +277,10 @@ public sealed class SqlDriver
|
||||
_factory = _explicitFactory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
|
||||
// The reader being replaced owns a SemaphoreSlim (R06:S-3). Disposing it is safe even with a stale
|
||||
// poll still inside it: the gate goes only when the last in-flight group releases it.
|
||||
var previous = _reader;
|
||||
_reader = new SqlPollReader(
|
||||
_factory,
|
||||
connectionString,
|
||||
@@ -271,6 +291,7 @@ public sealed class SqlDriver
|
||||
nullIsBad: options.NullIsBad,
|
||||
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
||||
logger: _logger);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
@@ -347,20 +368,39 @@ public sealed class SqlDriver
|
||||
// identifier.
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
var generation = BeginLifecycle();
|
||||
// LastSuccessfulRead is PRESERVED across the lifecycle states — it is the watchdog's evidence clock.
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
|
||||
// adopt a changed config BEFORE the tag table is built, so the table and the connection it
|
||||
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
|
||||
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
|
||||
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
|
||||
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
try
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
|
||||
BuildTagTable();
|
||||
BuildTagTable();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Review m-4: an unresolvable connectionStringRef or a malformed config threw here AFTER Initializing
|
||||
// was written, and no poll may end Initializing — so the driver latched it with no LastError. Fault
|
||||
// it like the liveness stage does: exception TYPE only on the operator surface (the factory's own
|
||||
// messages are already sanitized, but this method cannot rely on that), full exception to the log.
|
||||
var message =
|
||||
$"Sql driver '{_driverInstanceId}' could not adopt its configuration: {ex.GetType().Name}. " +
|
||||
"Check the connectionStringRef and the driver config JSON.";
|
||||
_logger.LogError(ex, "Sql driver {DriverInstanceId} configuration could not be adopted.", _driverInstanceId);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -369,7 +409,7 @@ public sealed class SqlDriver
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller tore the initialization down; not a database verdict.
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -385,8 +425,8 @@ public sealed class SqlDriver
|
||||
$"Check the database is reachable and the connection string is valid.";
|
||||
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
@@ -399,7 +439,7 @@ public sealed class SqlDriver
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -415,13 +455,13 @@ public sealed class SqlDriver
|
||||
_logger.LogError(ex,
|
||||
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
TransitionHostTo(HostState.Running);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)))
|
||||
TransitionHostTo(HostState.Running);
|
||||
_logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
|
||||
_driverInstanceId, Endpoint, Tags.Count);
|
||||
@@ -437,9 +477,10 @@ public sealed class SqlDriver
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var generation = BeginLifecycle();
|
||||
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -512,11 +553,37 @@ public sealed class SqlDriver
|
||||
// unreachable" and degrade a perfectly healthy driver.
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
// The generation this read started under: if a Shutdown/Initialize lands while it is in flight, its
|
||||
// verdict describes a superseded configuration and must not touch the newer health (R06:S-9).
|
||||
var generation = Interlocked.Read(ref _lifecycleGeneration);
|
||||
return await ReadThroughAsync(_reader, fullReferences, generation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReadAsync"/>'s body against a specific reader — the one the caller captured. Internal so a
|
||||
/// test can drive the "reader replaced between capture and use" race deterministically.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<DataValueSnapshot>> ReadThroughAsync(
|
||||
SqlPollReader reader, IReadOnlyList<string> fullReferences, long generation, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(snapshots);
|
||||
return snapshots;
|
||||
var result = await reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(result.Snapshots, result.AnsweredRefusalGroups, generation);
|
||||
return result.Snapshots;
|
||||
}
|
||||
catch (SqlPollReaderDisposedException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A rebind swapped the reader under this poll (review m-1: a redeploy re-initialises without
|
||||
// teardown, so the engine keeps polling). Retry ONCE on the current reader; if there is no newer
|
||||
// one (the driver itself was disposed), hand the engine an exception it is told not to classify —
|
||||
// it used to Degrade the driver as "poll failed: OperationCanceledException".
|
||||
var current = Volatile.Read(ref _reader);
|
||||
if (!ReferenceEquals(current, reader))
|
||||
return await ReadThroughAsync(current, fullReferences, generation, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref _classifiedByRead, ex);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -530,8 +597,12 @@ public sealed class SqlDriver
|
||||
_driverInstanceId, Endpoint);
|
||||
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
|
||||
// on the log sink via the LogWarning exception parameter above.
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (IsCurrentLifecycle(generation))
|
||||
{
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
}
|
||||
|
||||
// Mark THIS exception object as already classified before it goes back to the poll engine, which
|
||||
// routes it to HandlePollError next. The mark is what makes the deferral there exact — see that
|
||||
// method for why inferring it from the exception's type was not.
|
||||
@@ -603,9 +674,21 @@ public sealed class SqlDriver
|
||||
/// cadence rather than backed off from.</para>
|
||||
/// </summary>
|
||||
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
|
||||
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
/// <param name="answeredRefusals">
|
||||
/// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their
|
||||
/// members carry <see cref="SqlStatusCodes.BadConfigurationError"/>, which alone is not answer evidence
|
||||
/// (the planner mints it before any I/O), so the count is what makes them count as the database
|
||||
/// answering.
|
||||
/// </param>
|
||||
/// <param name="generation">
|
||||
/// The lifecycle generation the poll started under; a superseded one is ignored outright. Null (tests)
|
||||
/// means "current".
|
||||
/// </param>
|
||||
internal void ObservePollOutcome(
|
||||
IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0, long? generation = null)
|
||||
{
|
||||
if (snapshots.Count == 0) return;
|
||||
if (generation is { } started && !IsCurrentLifecycle(started)) return;
|
||||
|
||||
var good = 0;
|
||||
var unreachable = 0;
|
||||
@@ -620,6 +703,8 @@ public sealed class SqlDriver
|
||||
if (IsAnswerEvidence(snapshot.StatusCode)) answered++;
|
||||
}
|
||||
|
||||
answered += answeredRefusals;
|
||||
|
||||
if (unreachable > 0)
|
||||
{
|
||||
Degrade(
|
||||
@@ -655,17 +740,12 @@ public sealed class SqlDriver
|
||||
|
||||
if (answered == 0) return; // authoring-class Bad only — not a statement about the database.
|
||||
|
||||
if (good == 0)
|
||||
{
|
||||
// The database answered, just not with a value: an absent row, a NULL cell, a cell that would
|
||||
// not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly
|
||||
// as it was — but they are still proof the transport works, and the evidence clock the
|
||||
// comms-loss watchdog reads must say so.
|
||||
TouchLastSuccessfulRead();
|
||||
TransitionHostTo(HostState.Running);
|
||||
return;
|
||||
}
|
||||
|
||||
// Good values, OR a database that answered with refusals only — an absent row, a NULL cell, an
|
||||
// uncoercible cell, an over-cap result set. A refusal is the database answering: it advances the
|
||||
// evidence clock AND restores Healthy after a comms fault, exactly as a Good read does. Leaving the
|
||||
// state as it was latched a recovered database Degraded behind every poll that read only refusals.
|
||||
//
|
||||
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
|
||||
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
|
||||
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
||||
@@ -792,7 +872,11 @@ public sealed class SqlDriver
|
||||
/// </summary>
|
||||
/// <param name="project">Builds the snapshot to publish from the current one.</param>
|
||||
private void UpdateHealthUnlessFaulted(Func<DriverHealth, DriverHealth> project)
|
||||
=> UpdateHealth(current => current.State == DriverState.Faulted ? null : project(current));
|
||||
// Initializing too: only the initialize's own final write may end it — a poll landing mid-init
|
||||
// (a re-init without shutdown keeps the engine running) used to flip Initializing → Healthy.
|
||||
=> UpdateHealth(current => current.State is DriverState.Faulted or DriverState.Initializing
|
||||
? null
|
||||
: project(current));
|
||||
|
||||
/// <summary>
|
||||
/// The one read-modify-write of the health field, as a compare-and-swap retry loop.
|
||||
@@ -823,13 +907,31 @@ public sealed class SqlDriver
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Starts a lifecycle operation and returns its generation; every later one supersedes it.</summary>
|
||||
private long BeginLifecycle()
|
||||
{
|
||||
lock (_healthLock) return ++_lifecycleGeneration;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
|
||||
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
|
||||
|
||||
/// <summary>The current lifecycle generation. Test observation of the S-9 guard.</summary>
|
||||
internal long LifecycleGenerationForTests => Interlocked.Read(ref _lifecycleGeneration);
|
||||
|
||||
/// <summary>
|
||||
/// Unconditional publish of a new health snapshot, for the lifecycle transitions that are
|
||||
/// last-writer-wins by definition (Initialize / Shutdown). Everything driven by a poll goes through
|
||||
/// <see cref="UpdateHealth"/> instead, because it has to read the current snapshot first.
|
||||
/// A lifecycle write that applies only while <paramref name="generation"/> is current — atomic against
|
||||
/// <see cref="BeginLifecycle"/>, so a superseded initialize/shutdown never overwrites a newer one.
|
||||
/// </summary>
|
||||
/// <param name="value">The health snapshot to publish.</param>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
if (_lifecycleGeneration != generation) return false;
|
||||
Volatile.Write(ref _health, value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
|
||||
private void TransitionHostTo(HostState newState)
|
||||
@@ -867,10 +969,15 @@ public sealed class SqlDriver
|
||||
table[entry.RawPath] = definition;
|
||||
else
|
||||
{
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, "TagConfig did not map to a definition"));
|
||||
// The reason is the DEPLOY GATE's own explanation (R06:S-4) — SqlTagDefinitionFactory.Inspect
|
||||
// asks this same parser and then says why — so /hosts names e.g. the deferred Query model
|
||||
// instead of a generic "did not map" for a tag the gate already warned about.
|
||||
var reason = SqlTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault()
|
||||
?? "TagConfig did not map to a definition";
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, reason));
|
||||
_logger.LogWarning(
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
|
||||
_driverInstanceId, entry.RawPath, reason);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1150,7 +1257,20 @@ public sealed class SqlDriver
|
||||
/// <c>await using</c> does not leak the poll loops.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
|
||||
// Only on the FINAL dispose: a Shutdown → Initialize cycle on the same instance keeps polling
|
||||
// through this reader when the init does not rebind (no rebinder / placeholder config).
|
||||
_reader.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Re-runs <see cref="ApplyBinding"/> on the current binding — a real reader swap, for the m-1 race test.</summary>
|
||||
internal void RebindForTests() => ApplyBinding(_options, _dialect, _connectionString);
|
||||
|
||||
/// <summary>The live poll reader. Test observation of the S-3 lifecycle only.</summary>
|
||||
internal SqlPollReader ReaderForTests => _reader;
|
||||
|
||||
/// <summary>
|
||||
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
|
||||
|
||||
@@ -194,8 +194,14 @@ public static class SqlDriverFactoryExtensions
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// The parser's message AND the exception carrying it are withheld (R06:S-8), matching
|
||||
// SqlDriverProbe and SqlDriverBrowser: System.Text.Json quotes the offending token, and a pasted
|
||||
// connection-string literal is exactly the malformed text that lands here. This message becomes
|
||||
// InitializeFailed -> health LastError -> /hosts. Line/byte position carry no content, so they stay.
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON (line "
|
||||
+ $"{(ex.LineNumber is { } line ? line + 1 : (object)"?")}, byte {ex.BytePositionInLine?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "?"}). The parser's message is withheld because the malformed text may "
|
||||
+ "carry a connection string.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,15 +230,23 @@ public static class SqlGroupPlanner
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
// Row cap (R06:S-1): one row per distinct key is all this plan can use. It reads up to TWICE that, so a
|
||||
// handful of stray duplicates stays inside the cap and is attributed PER KEY by the reader (only the
|
||||
// duplicated key's tags go BadConfigurationError) — and asks for one row more than that, so only a
|
||||
// gross excess (a history/events table, unattributable by construction because the rest is unread)
|
||||
// fails the whole group. Memory stays O(keys) either way.
|
||||
var rowCap = values.Count * 2;
|
||||
var sql = new StringBuilder("SELECT ")
|
||||
.Append(dialect.RowLimitPrefix(rowCap + 1))
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.Append(dialect.RowLimitSuffix(rowCap + 1))
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected, rowCap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -270,14 +278,18 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
// Capped at TWO rows (R06:S-1): the selector must identify one row, and the second is the proof it
|
||||
// does not. A TOP 1 alone would hide that ambiguity (see SqlPollReader.SelectWideRow); no cap at all
|
||||
// materialised every matching row of an events table on every poll.
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
"SELECT ", dialect.RowLimitPrefix(2), columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker,
|
||||
dialect.RowLimitSuffix(2));
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
[first.RowSelectorValue], members, selected, RowCap: 1);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// no subscription state and no connection between polls, so the driver shell can wrap it in
|
||||
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlPollReader
|
||||
public sealed class SqlPollReader : IDisposable
|
||||
{
|
||||
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
|
||||
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
|
||||
@@ -52,13 +54,41 @@ public sealed class SqlPollReader
|
||||
|
||||
/// <summary>
|
||||
/// Caps concurrently executing group queries — and therefore concurrently held connections
|
||||
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
|
||||
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
|
||||
/// timed-out-but-still-running group releasing into a disposed semaphore.
|
||||
/// (design §8.4). Disposed by <see cref="Dispose"/> only once every group that ever acquired it has
|
||||
/// released it (<see cref="_refs"/>), so a timed-out-but-still-running group can never release into a
|
||||
/// disposed semaphore (R06:S-3 — it used to be never disposed, one per reinitialize).
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate;
|
||||
|
||||
private long _lastContractWarningTicks;
|
||||
/// <summary>
|
||||
/// Owner reference (1) plus one per in-flight group. <see cref="Dispose"/> drops the owner's; whoever
|
||||
/// takes it to zero disposes <see cref="_gate"/>. Zero is terminal — no group can start after it.
|
||||
/// </summary>
|
||||
private int _refs = 1;
|
||||
|
||||
private int _disposed;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Per-key last-warned instant for the per-poll warnings (R06:S-6): a frozen group's BadTimeout and a
|
||||
/// failing group's query error used to log every poll, per group, indefinitely — 20 groups at 1 s was
|
||||
/// 20 lines a second carrying SQL text. Keyed by the plan's SQL text, so bounded by the plan count.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, long> _lastWarnedTicks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Tags whose uncoercible cell has been warned about — once per tag per reader (the refusal rule).</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _coerceWarned = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The resolved definitions, slot map and plans for each reference list a caller polls (R06:S-7). A
|
||||
/// poll loop passes the SAME list instance every cycle, so it is planned once instead of every poll.
|
||||
/// Weakly keyed: an unsubscribed list (and its entry) is collected with it. Invalidation is structural —
|
||||
/// each entry remembers the definition INSTANCES it was built from, and any reference resolving to a
|
||||
/// different instance (a catalog-gate swap) rebuilds it.
|
||||
/// </summary>
|
||||
private readonly ConditionalWeakTable<IReadOnlyList<string>, PollShape> _shapes = new();
|
||||
|
||||
private int _plansBuilt;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
|
||||
/// <param name="factory">
|
||||
@@ -154,26 +184,87 @@ public sealed class SqlPollReader
|
||||
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
=> (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReadAsync"/> plus what the snapshots alone cannot say: how many groups the database
|
||||
/// ANSWERED with more rows than the plan can use (R06:S-1). Those groups publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> — the same code an un-plannable definition gets
|
||||
/// before any I/O — so the driver needs this count to treat them as the answer evidence they are.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to read.</param>
|
||||
/// <param name="cancellationToken">The caller's token; see <see cref="ReadAsync"/>.</param>
|
||||
/// <returns>The snapshots and the answered-refusal count.</returns>
|
||||
public async Task<SqlPollResult> ReadDetailedAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
if (results.Length == 0) return results;
|
||||
if (results.Length == 0) return new SqlPollResult(results, 0);
|
||||
|
||||
var readAt = DateTime.UtcNow;
|
||||
var shape = GetShape(fullReferences);
|
||||
|
||||
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
|
||||
// whose members are the sole thing the slice-back walks.
|
||||
// A miss is this tag's own problem (BadNodeIdUnknown) and never reaches the planner, whose members
|
||||
// are the sole thing the slice-back walks.
|
||||
for (var i = 0; i < shape.Resolved.Length; i++)
|
||||
{
|
||||
if (shape.Resolved[i] is null)
|
||||
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
|
||||
}
|
||||
|
||||
var answeredRefusals = shape.Definitions.Count > 0
|
||||
? await ReadGroupsAsync(shape, results, readAt, cancellationToken).ConfigureAwait(false)
|
||||
: 0;
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return new SqlPollResult(results, answeredRefusals);
|
||||
}
|
||||
|
||||
/// <summary>How many times a reference list has been (re)planned. Test observation of the S-7 cache.</summary>
|
||||
internal int PlansBuiltForTests => Volatile.Read(ref _plansBuilt);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached shape for <paramref name="fullReferences"/>, rebuilding it when any reference now
|
||||
/// resolves to a different definition instance than the one it was built from.
|
||||
/// </summary>
|
||||
private PollShape GetShape(IReadOnlyList<string> fullReferences)
|
||||
{
|
||||
if (_shapes.TryGetValue(fullReferences, out var cached) && cached.Resolved.Length == fullReferences.Count)
|
||||
{
|
||||
var current = true;
|
||||
for (var i = 0; i < fullReferences.Count && current; i++)
|
||||
{
|
||||
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
|
||||
current = ReferenceEquals(definition, cached.Resolved[i]);
|
||||
}
|
||||
|
||||
if (current) return cached;
|
||||
}
|
||||
|
||||
var shape = BuildShape(fullReferences);
|
||||
_shapes.AddOrUpdate(fullReferences, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
/// <summary>Resolves, slots and plans one reference list.</summary>
|
||||
private PollShape BuildShape(IReadOnlyList<string> fullReferences)
|
||||
{
|
||||
Interlocked.Increment(ref _plansBuilt);
|
||||
|
||||
var resolved = new SqlTagDefinition?[fullReferences.Count];
|
||||
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
|
||||
var slots = new Dictionary<SqlTagDefinition, List<int>>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
|
||||
if (definition is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
|
||||
continue;
|
||||
}
|
||||
resolved[i] = definition;
|
||||
if (definition is null) continue;
|
||||
|
||||
definitions.Add(definition);
|
||||
if (!slots.TryGetValue(definition, out var positions))
|
||||
@@ -181,45 +272,54 @@ public sealed class SqlPollReader
|
||||
positions.Add(i);
|
||||
}
|
||||
|
||||
if (definitions.Count > 0)
|
||||
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
|
||||
if (definitions.Count == 0) return new PollShape(resolved, definitions, slots, [], PlanningFailed: false);
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
|
||||
private async Task ReadGroupsAsync(
|
||||
List<SqlTagDefinition> definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SqlQueryPlan> plans;
|
||||
try
|
||||
{
|
||||
plans = SqlGroupPlanner.Plan(definitions, _dialect);
|
||||
return new PollShape(resolved, definitions, slots, SqlGroupPlanner.Plan(definitions, _dialect), PlanningFailed: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
|
||||
// reaching here means a definition was built past the parser. It is an authoring fault, not a
|
||||
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
|
||||
// Logged once per (re)plan, which the cache makes once per shape rather than once per poll.
|
||||
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
|
||||
definitions.Count);
|
||||
foreach (var definition in definitions)
|
||||
return new PollShape(resolved, definitions, slots, [], PlanningFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One reference list's resolved definitions, slot map and plans — immutable once built.</summary>
|
||||
private sealed record PollShape(
|
||||
SqlTagDefinition?[] Resolved,
|
||||
List<SqlTagDefinition> Definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> Slots,
|
||||
IReadOnlyList<SqlQueryPlan> Plans,
|
||||
bool PlanningFailed);
|
||||
|
||||
/// <summary>
|
||||
/// Runs every planned group concurrently and slices the results back. Returns how many groups came
|
||||
/// back with an answered refusal (over the row cap, or a duplicated key).
|
||||
/// </summary>
|
||||
private async Task<int> ReadGroupsAsync(
|
||||
PollShape shape,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var slots = shape.Slots;
|
||||
var plans = shape.Plans;
|
||||
if (shape.PlanningFailed)
|
||||
{
|
||||
foreach (var definition in shape.Definitions)
|
||||
{
|
||||
if (!slots.TryGetValue(definition, out var positions)) continue;
|
||||
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
|
||||
@@ -233,8 +333,18 @@ public sealed class SqlPollReader
|
||||
// leaves a sibling group running against a connection nobody is watching.
|
||||
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
|
||||
|
||||
// Surfaced AFTER WhenAll, as one typed exception: a group throwing it would turn WhenAll into a plain
|
||||
// TaskCanceledException and the caller could no longer tell "superseded reader" from a caller cancel.
|
||||
if (outcomes.Any(o => ReferenceEquals(o, GroupResult.ReaderDisposed))) throw new SqlPollReaderDisposedException();
|
||||
|
||||
var answeredRefusals = 0;
|
||||
for (var g = 0; g < plans.Count; g++)
|
||||
Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
{
|
||||
var refusedSome = Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
if (refusedSome || ReferenceEquals(outcomes[g], GroupResult.RowCapExceeded)) answeredRefusals++;
|
||||
}
|
||||
|
||||
return answeredRefusals;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -273,6 +383,22 @@ public sealed class SqlPollReader
|
||||
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
|
||||
/// </summary>
|
||||
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
// A reader replaced by a reinitialize (and disposed) can still be holding a stale poll: it runs no
|
||||
// new group. Cancellation is the honest answer — the poll that asked has been superseded.
|
||||
if (!TryAddRef()) return GroupResult.ReaderDisposed;
|
||||
|
||||
try
|
||||
{
|
||||
return await RunGroupCoreAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseRef();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GroupResult> RunGroupCoreAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
@@ -288,6 +414,9 @@ public sealed class SqlPollReader
|
||||
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
// The work holds its OWN reference: it can outlive this method (a timed-out group is abandoned
|
||||
// still running) and its finally releases the slot into _gate, which must therefore still exist.
|
||||
AddRefForWork();
|
||||
work = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
@@ -299,6 +428,7 @@ public sealed class SqlPollReader
|
||||
{
|
||||
_gate.Release();
|
||||
deadline.Dispose();
|
||||
ReleaseRef();
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
@@ -334,6 +464,41 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the concurrency gate once no group still holds it. Idempotent; safe while groups are in
|
||||
/// flight (the last one out disposes it). A disposed reader starts no new group.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0) ReleaseRef();
|
||||
}
|
||||
|
||||
/// <summary>True once <see cref="_gate"/> has actually been disposed. Test observation only.</summary>
|
||||
internal bool GateDisposedForTests => Volatile.Read(ref _refs) == 0;
|
||||
|
||||
/// <summary>Takes a reference unless the reader is already fully released.</summary>
|
||||
private bool TryAddRef()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// A disposed reader starts no new group even while older ones are still in flight (review m-2):
|
||||
// the gate would survive until they finish, but a replaced reader must not take new work.
|
||||
if (Volatile.Read(ref _disposed) != 0) return false;
|
||||
var current = Volatile.Read(ref _refs);
|
||||
if (current == 0) return false;
|
||||
if (Interlocked.CompareExchange(ref _refs, current + 1, current) == current) return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A further reference for work started under an already-held one (so never from zero).</summary>
|
||||
private void AddRefForWork() => Interlocked.Increment(ref _refs);
|
||||
|
||||
/// <summary>Drops a reference; the one that reaches zero disposes the gate.</summary>
|
||||
private void ReleaseRef()
|
||||
{
|
||||
if (Interlocked.Decrement(ref _refs) == 0) _gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
|
||||
private TimeSpan Remaining(Stopwatch clock)
|
||||
{
|
||||
@@ -346,11 +511,14 @@ public sealed class SqlPollReader
|
||||
{
|
||||
// The table is named for the same reason the contract warnings name it: under a partial outage this
|
||||
// line repeats every poll, and "which source is frozen" is the only question the operator has.
|
||||
_logger.LogWarning(
|
||||
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
|
||||
"timeout; publishing BadTimeout.",
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds);
|
||||
if (ShouldWarn("timeout:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
|
||||
"timeout; publishing BadTimeout. Repeats for this group are suppressed for {Interval} s.",
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds, (int)ContractWarningInterval.TotalSeconds);
|
||||
}
|
||||
|
||||
AbandonedTask.Detach(work);
|
||||
return GroupResult.TimedOut;
|
||||
@@ -412,9 +580,13 @@ public sealed class SqlPollReader
|
||||
{
|
||||
// The connection opened, so the server is up — this query is what failed (an identifier that
|
||||
// is not in the catalog, a lock, a permission). That is this group's problem alone.
|
||||
_logger.LogWarning(
|
||||
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
|
||||
plan.Model, plan.Members.Count, plan.SqlText);
|
||||
if (ShouldWarn("failed:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}. Repeats for this group are " +
|
||||
"suppressed for {Interval} s.",
|
||||
plan.Model, plan.Members.Count, plan.SqlText, (int)ContractWarningInterval.TotalSeconds);
|
||||
}
|
||||
return GroupResult.Failed;
|
||||
}
|
||||
}
|
||||
@@ -442,8 +614,11 @@ public sealed class SqlPollReader
|
||||
columnIndex[selected[c]] = c;
|
||||
}
|
||||
|
||||
// The client-side half of the row cap (R06:S-1): the statement already asks for RowCap + 1 rows, but a
|
||||
// provider that cannot spell a limit must not turn that into an unbounded read either.
|
||||
var limit = plan.RowCap is { } cap ? cap + 1 : int.MaxValue;
|
||||
var rows = new List<object?[]>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
while (rows.Count < limit && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = new object?[selected.Count];
|
||||
for (var c = 0; c < selected.Count; c++)
|
||||
@@ -451,6 +626,27 @@ public sealed class SqlPollReader
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
if (plan.RowCap is { } rowCap && rows.Count > rowCap)
|
||||
{
|
||||
// Per plan, not reader-wide: with two ambiguous groups the reader-wide limiter could name only the
|
||||
// first table forever, and with health still Healthy this line is the operator's only pointer.
|
||||
if (ShouldWarn("rowcap:" + plan.SqlText))
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = plan.Model == SqlTagModel.KeyValue
|
||||
? string.Concat(first.KeyColumn, " IN (", (rowCap / 2).ToString(CultureInfo.InvariantCulture), " key(s))")
|
||||
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more rows than the {Model} selector '{Selector}' can attribute " +
|
||||
"(at most {Cap}) in one poll, so the selector is ambiguous — a history/events table, or a " +
|
||||
"where column that is not unique. Its {Members} tag(s) publish BadConfigurationError and " +
|
||||
"nothing past row {Limit} is read. Point the tag at a current-values table or a latest-row view.",
|
||||
first.Table, plan.Model, selector, rowCap, plan.Members.Count, rowCap + 1);
|
||||
}
|
||||
|
||||
return GroupResult.RowCapExceeded;
|
||||
}
|
||||
|
||||
return GroupResult.Ok(rows, columnIndex, typeNames);
|
||||
}
|
||||
|
||||
@@ -470,8 +666,11 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
|
||||
private void Slice(
|
||||
/// <summary>
|
||||
/// Maps one group's outcome onto every slot its members occupy in the caller's list. Returns whether any
|
||||
/// member was refused on an ANSWERED result (a duplicated key) — answer evidence the driver must count.
|
||||
/// </summary>
|
||||
private bool Slice(
|
||||
SqlQueryPlan plan,
|
||||
GroupResult outcome,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
@@ -481,9 +680,11 @@ public sealed class SqlPollReader
|
||||
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
|
||||
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
|
||||
// they are group-wide facts, and doing them per member would report a contract violation N times.
|
||||
HashSet<string>? duplicatedKeys = null;
|
||||
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
|
||||
? IndexByKey(plan, outcome)
|
||||
? IndexByKey(plan, outcome, out duplicatedKeys)
|
||||
: null;
|
||||
var refusedSome = false;
|
||||
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
|
||||
? SelectWideRow(plan, outcome)
|
||||
: null;
|
||||
@@ -493,26 +694,45 @@ public sealed class SqlPollReader
|
||||
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
|
||||
if (!slots.TryGetValue(member, out var positions)) continue;
|
||||
|
||||
var snapshot = outcome.Succeeded
|
||||
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
|
||||
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||||
DataValueSnapshot snapshot;
|
||||
if (!outcome.Succeeded)
|
||||
{
|
||||
snapshot = new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||||
}
|
||||
else if (duplicatedKeys is not null && member.KeyValue is { } key && duplicatedKeys.Contains(key))
|
||||
{
|
||||
// More than one row for THIS tag's key: its selector is ambiguous — the same rule a where-pair
|
||||
// follows — so it is a configuration error for this key's tags only. Every other key in the
|
||||
// group is unaffected, whatever else is present or absent (R06:S-1 review I-1: it used to be
|
||||
// last-row-wins Good, or the whole group Bad, depending on an unrelated key's absence).
|
||||
snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
|
||||
refusedSome = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot = MapMember(plan, member, outcome, byKey, wideRow, readAt);
|
||||
}
|
||||
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return refusedSome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
|
||||
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
|
||||
/// violation is reported through a rate-limited warning rather than silently.
|
||||
/// Builds the key → row index a key-value plan slices through, and the set of keys that came back more
|
||||
/// than once — a source breaking the one-row-per-key contract (design §3.6). Those keys' tags publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/>; the violation is also reported through a
|
||||
/// per-plan rate-limited warning.
|
||||
/// </summary>
|
||||
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
|
||||
private Dictionary<string, object?[]> IndexByKey(
|
||||
SqlQueryPlan plan, GroupResult outcome, out HashSet<string>? duplicatedKeys)
|
||||
{
|
||||
duplicatedKeys = null;
|
||||
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
|
||||
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
|
||||
return index;
|
||||
|
||||
var duplicated = 0;
|
||||
foreach (var row in outcome.Rows)
|
||||
{
|
||||
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
|
||||
@@ -523,20 +743,16 @@ public sealed class SqlPollReader
|
||||
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
|
||||
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
|
||||
if (key is null) continue;
|
||||
if (!index.TryAdd(key, row))
|
||||
{
|
||||
index[key] = row;
|
||||
duplicated++;
|
||||
}
|
||||
if (!index.TryAdd(key, row)) (duplicatedKeys ??= new HashSet<string>(StringComparer.Ordinal)).Add(key);
|
||||
}
|
||||
|
||||
if (duplicated > 0 && ShouldWarnAboutContract())
|
||||
if (duplicatedKeys is not null && ShouldWarn("dupkey:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
|
||||
"last row wins. The key-value model requires one row per key — point the tag at a " +
|
||||
"current-values table or an operator-authored latest-per-key view.",
|
||||
plan.Members[0].Table, duplicated);
|
||||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll (e.g. " +
|
||||
"'{Example}'); those keys' tags publish BadConfigurationError. The key-value model requires one " +
|
||||
"row per key — point the tag at a current-values table or an operator-authored latest-per-key view.",
|
||||
plan.Members[0].Table, duplicatedKeys.Count, duplicatedKeys.First());
|
||||
}
|
||||
|
||||
return index;
|
||||
@@ -549,15 +765,14 @@ public sealed class SqlPollReader
|
||||
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
|
||||
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
|
||||
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
|
||||
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
|
||||
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
|
||||
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
|
||||
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
|
||||
/// (<c>Rows.Count > 1</c>), converting a loud misconfiguration into a permanently silent one —
|
||||
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> at least fixes <em>which</em> rows can
|
||||
/// be first. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// poll; the warning is what gets it fixed.</para>
|
||||
/// <para><b>Why the where-pair form is capped at TWO rows, not one (R06:S-1).</b> A <c>TOP 1</c> with no
|
||||
/// <c>ORDER BY</c> is not deterministic — it returns whichever row the plan produces first — and it would
|
||||
/// destroy the only evidence that the selector is ambiguous, converting a loud misconfiguration into a
|
||||
/// permanently silent one. No limit at all materialised every matching row of an events table on every
|
||||
/// poll. Two rows keeps the evidence and bounds the read: a second row makes the group publish
|
||||
/// <c>BadConfigurationError</c> in <c>MaterialiseAsync</c>, so a where-pair group never reaches this
|
||||
/// method with more than one row. The multi-row warning below is now reached only by a plan with no
|
||||
/// cap.</para>
|
||||
/// <para><b>⚠ The <c>topByTimestamp</c> answer is only defined when the ordering column is unique</b>
|
||||
/// <c>ORDER BY ts DESC</c> plus a one-row limit over a column with ties returns an
|
||||
/// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never
|
||||
@@ -572,7 +787,7 @@ public sealed class SqlPollReader
|
||||
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
|
||||
{
|
||||
if (outcome.Rows.Count == 0) return null;
|
||||
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
|
||||
if (outcome.Rows.Count > 1 && ShouldWarn("multirow:" + plan.SqlText))
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
|
||||
@@ -642,9 +857,14 @@ public sealed class SqlPollReader
|
||||
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
|
||||
if (!TryCoerce(cell, target, out var value))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
|
||||
member.Name, cell.GetType().Name, target);
|
||||
// A per-tag refusal: warned ONCE per tag (per reader), never per poll (R06:S-6).
|
||||
if (_coerceWarned.TryAdd(member.Name, 0))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type; it publishes " +
|
||||
"BadTypeMismatch (warned once per tag).",
|
||||
member.Name, cell.GetType().Name, target);
|
||||
}
|
||||
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
@@ -711,13 +931,21 @@ public sealed class SqlPollReader
|
||||
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
|
||||
private bool ShouldWarnAboutContract()
|
||||
/// <summary>Rate-limits one per-poll warning by key; true at most once per <see cref="ContractWarningInterval"/>.</summary>
|
||||
private bool ShouldWarn(string key)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var last = Interlocked.Read(ref _lastContractWarningTicks);
|
||||
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||||
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
|
||||
while (true)
|
||||
{
|
||||
if (!_lastWarnedTicks.TryGetValue(key, out var last))
|
||||
{
|
||||
if (_lastWarnedTicks.TryAdd(key, now)) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||||
if (_lastWarnedTicks.TryUpdate(key, now, last)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
|
||||
@@ -747,6 +975,17 @@ public sealed class SqlPollReader
|
||||
public static GroupResult Failed { get; } =
|
||||
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The reader was disposed before this group could start; surfaced as a typed exception.</summary>
|
||||
public static GroupResult ReaderDisposed { get; } =
|
||||
new(SqlStatusCodes.BadInternalError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>
|
||||
/// The database answered with more rows than the plan can use — an ambiguous selector (R06:S-1).
|
||||
/// A configuration fact, not a database fault; the driver counts it as answer evidence.
|
||||
/// </summary>
|
||||
public static GroupResult RowCapExceeded { get; } =
|
||||
new(SqlStatusCodes.BadConfigurationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
|
||||
public uint FailureStatus { get; }
|
||||
|
||||
@@ -775,6 +1014,21 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A poll reached a <see cref="SqlPollReader"/> after it was disposed — a rebind replaced it, or the driver
|
||||
/// was disposed. Not a database verdict: the driver retries on the current reader, and never degrades for it.
|
||||
/// </summary>
|
||||
public sealed class SqlPollReaderDisposedException()
|
||||
: OperationCanceledException("The Sql poll reader was disposed (replaced by a rebind, or the driver was disposed).");
|
||||
|
||||
/// <summary>One poll's snapshots plus the answer evidence they cannot carry themselves.</summary>
|
||||
/// <param name="Snapshots">One snapshot per requested reference, at the same index.</param>
|
||||
/// <param name="AnsweredRefusalGroups">
|
||||
/// Groups the database ANSWERED but whose result the reader refused in whole or part — an over-cap result
|
||||
/// set, or a duplicated key. Those members carry <see cref="SqlStatusCodes.BadConfigurationError"/>.
|
||||
/// </param>
|
||||
public sealed record SqlPollResult(IReadOnlyList<DataValueSnapshot> Snapshots, int AnsweredRefusalGroups);
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
|
||||
/// them back with.
|
||||
|
||||
@@ -55,6 +55,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
|
||||
/// reader's map from result-set ordinal to source column.
|
||||
/// </param>
|
||||
/// <param name="RowCap">
|
||||
/// The most rows the reader will attribute before refusing the whole group — twice the distinct key count
|
||||
/// (key-value: headroom so stray duplicates are attributed per key) or 1 (a where-pair wide row: one
|
||||
/// selector, so any second row is the group's ambiguity); <see langword="null"/> when the statement
|
||||
/// already limits itself (topByTimestamp). <see cref="SqlText"/> asks for <c>RowCap + 1</c>, and the
|
||||
/// reader stops there: receiving the extra row means a history/events table or a non-unique where column,
|
||||
/// and the group publishes <c>BadConfigurationError</c> instead of materialising every matching row on
|
||||
/// every poll (R06:S-1).
|
||||
/// </param>
|
||||
public sealed record SqlQueryPlan(
|
||||
SqlTagModel Model,
|
||||
object GroupKey,
|
||||
@@ -63,7 +72,8 @@ public sealed record SqlQueryPlan(
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
IReadOnlyList<string> SelectedColumns,
|
||||
int? RowCap = null)
|
||||
{
|
||||
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
|
||||
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
|
||||
|
||||
Reference in New Issue
Block a user