Merge commit '82380d2b' into remed/integration-w3
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
|
||||
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
|
||||
/// trail and comment history.
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
|
||||
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
|
||||
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -34,14 +36,58 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
|
||||
/// chronological; nullable timestamps and audit fields are stored as NULL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The comment trail is NOT on the condition row.</b> It used to be serialized whole into
|
||||
/// <c>comments_json</c>, so every transition rewrote — and replicated — the alarm's entire
|
||||
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
|
||||
/// <see cref="CommentsTable"/>, one row per entry keyed by <c>(scripted_alarm_id, seq)</c>,
|
||||
/// where <c>seq</c> is the entry's ordinal in the alarm's trail. A transition inserts only the
|
||||
/// entries it appended, so the condition row stays a fixed size and a transition's write is
|
||||
/// O(1) however long the history is. <c>comments_json</c> is kept, always written as
|
||||
/// <c>"[]"</c>, so the condition table's shape — and with it the pair's replication digest
|
||||
/// for that table — does not change and an old-shape row stays readable;
|
||||
/// <see cref="MigrateInlineComments"/> copies any inline trail across (the alarm's next
|
||||
/// save clears the column).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the ordinal, and not a content hash, is the key.</b> Both halves of a redundant pair
|
||||
/// run the engine and both apply every operator command, each stamping the entry with its own
|
||||
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
|
||||
/// converged. Keyed by ordinal, the two nodes' copies of entry <i>n</i> share one primary key
|
||||
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
|
||||
/// entry. Entries go in with <c>INSERT OR IGNORE</c> (<see cref="InsertCommentSql"/>), so an
|
||||
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
|
||||
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
|
||||
/// stored trail, with a Warning (see the store's divergence handling).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AlarmConditionStateSchema
|
||||
{
|
||||
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
|
||||
public const string StateTable = "alarm_condition_state";
|
||||
|
||||
/// <summary>Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.</summary>
|
||||
public const string CommentsTable = "alarm_condition_comment";
|
||||
|
||||
/// <summary>What <c>comments_json</c> holds once the trail lives in <see cref="CommentsTable"/>.</summary>
|
||||
public const string EmptyInlineComments = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table if it does not already exist. Idempotent.
|
||||
/// The one append statement for <see cref="CommentsTable"/>, shared by the migration and the
|
||||
/// store. <c>OR IGNORE</c> is what makes the trail append-only: an ordinal already held is
|
||||
/// never rewritten. Parameters: <c>@Id @Seq @Ts @User @Kind @Text</c>.
|
||||
/// </summary>
|
||||
public const string InsertCommentSql =
|
||||
"""
|
||||
INSERT OR IGNORE INTO alarm_condition_comment
|
||||
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
|
||||
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
|
||||
""";
|
||||
|
||||
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table and the comment table if they do not already exist. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="connection">
|
||||
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
|
||||
@@ -55,10 +101,11 @@ public static class AlarmConditionStateSchema
|
||||
|
||||
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
|
||||
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
|
||||
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
|
||||
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
|
||||
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
|
||||
// transition column).
|
||||
// so they are NOT NULL. comments_json is never null either — it is the literal "[]" since the
|
||||
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
|
||||
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
|
||||
// The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm has no such history yet. updated_at_utc
|
||||
// carries LastTransitionUtc (there is no dedicated transition column).
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_state (
|
||||
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -76,7 +123,161 @@ public static class AlarmConditionStateSchema
|
||||
comments_json TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
|
||||
scripted_alarm_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
timestamp_utc TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
PRIMARY KEY (scripted_alarm_id, seq)
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="MigrateInlineComments"/> run did.</summary>
|
||||
/// <param name="RowsMoved">Condition rows for which at least one inline entry was copied across.</param>
|
||||
/// <param name="UnparseableAlarmIds">
|
||||
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
|
||||
/// so an unmigratable history is visible rather than skipped silently on every boot.
|
||||
/// </param>
|
||||
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList<string> UnparseableAlarmIds);
|
||||
|
||||
/// <summary>
|
||||
/// Copies every condition row's inline <c>comments_json</c> trail into
|
||||
/// <see cref="CommentsTable"/>. Idempotent; it never writes the condition row itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Run it AFTER the tables are registered for replication</b> — it writes rows, and a
|
||||
/// row written before the capture triggers exist never reaches the peer (the same
|
||||
/// DDL → register → writes rule <c>AlarmSfLegacyMigrator</c> follows).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Copy, don't clear.</b> Resetting <c>comments_json</c> here would be a full-row UPDATE
|
||||
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
|
||||
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
|
||||
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
|
||||
/// load merges an inline trail with the table by ordinal (the table wins), and the
|
||||
/// engine's next save of that alarm writes <c>"[]"</c> with the alarm's real state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each entry lands at its ordinal in the inline array via <see cref="InsertCommentSql"/>,
|
||||
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
|
||||
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
|
||||
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
|
||||
/// does not parse is left exactly as it is (the pre-split store could not load it either)
|
||||
/// and reported in <see cref="InlineCommentMigrationResult.UnparseableAlarmIds"/> rather
|
||||
/// than failing host start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="connection">An already-open connection from <c>ILocalDb.CreateConnection()</c>.</param>
|
||||
/// <returns>What was copied, and which rows could not be read.</returns>
|
||||
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
|
||||
var pending = new List<(string AlarmId, string Json)>();
|
||||
using (var select = connection.CreateCommand())
|
||||
{
|
||||
select.CommandText =
|
||||
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
|
||||
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
|
||||
using var reader = select.ExecuteReader();
|
||||
while (reader.Read())
|
||||
pending.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
|
||||
var migrated = 0;
|
||||
var unparseable = new List<string>();
|
||||
foreach (var (alarmId, json) in pending)
|
||||
{
|
||||
IReadOnlyList<InlineComment> entries;
|
||||
try
|
||||
{
|
||||
entries = ParseInlineComments(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
unparseable.Add(alarmId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var inserted = 0;
|
||||
using var tx = connection.BeginTransaction();
|
||||
for (var seq = 0; seq < entries.Count; seq++)
|
||||
{
|
||||
using var insert = connection.CreateCommand();
|
||||
insert.Transaction = tx;
|
||||
insert.CommandText = InsertCommentSql;
|
||||
insert.Parameters.AddWithValue("@Id", alarmId);
|
||||
insert.Parameters.AddWithValue("@Seq", seq);
|
||||
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
|
||||
insert.Parameters.AddWithValue("@User", entries[seq].User);
|
||||
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
|
||||
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
|
||||
inserted += insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
if (inserted > 0)
|
||||
migrated++;
|
||||
}
|
||||
|
||||
return new InlineCommentMigrationResult(migrated, unparseable);
|
||||
}
|
||||
|
||||
/// <summary>Formats a comment timestamp for <see cref="CommentsTable"/> (round-trip "O" text).</summary>
|
||||
/// <param name="value">The timestamp.</param>
|
||||
/// <returns>The round-trip text form.</returns>
|
||||
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Parses a <see cref="CommentsTable"/> timestamp, preserving its <see cref="DateTimeKind"/>.</summary>
|
||||
/// <param name="value">The round-trip text form.</param>
|
||||
/// <returns>The timestamp.</returns>
|
||||
public static DateTime ParseTimestamp(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a legacy inline <c>comments_json</c> trail exactly as the pre-split store did
|
||||
/// (<see cref="JsonSerializerDefaults.Web"/>; null strings become empty).
|
||||
/// </summary>
|
||||
/// <param name="json">The column value.</param>
|
||||
/// <returns>The entries in trail order; empty for a blank or <c>"[]"</c> column.</returns>
|
||||
/// <exception cref="JsonException">The column is not a valid trail.</exception>
|
||||
public static IReadOnlyList<InlineComment> ParseInlineComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
var dtos = JsonSerializer.Deserialize<List<InlineCommentDto>>(json, InlineJsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return [];
|
||||
return dtos
|
||||
.Select(d => new InlineComment(
|
||||
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One comment-trail entry as stored — the persistence-side mirror of the engine's <c>AlarmComment</c>.</summary>
|
||||
/// <param name="TimestampUtc">When the action happened.</param>
|
||||
/// <param name="User">Identity of the actor.</param>
|
||||
/// <param name="Kind">Classification (Acknowledge, Confirm, AddComment, …).</param>
|
||||
/// <param name="Text">Operator-supplied or engine-generated text.</param>
|
||||
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
|
||||
|
||||
/// <summary>The pre-split on-disk shape of one entry in <c>comments_json</c>.</summary>
|
||||
private sealed class InlineCommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded.</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Classification of the entry.</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Entry text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ public sealed class MTConnectDriverOptions
|
||||
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
|
||||
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
|
||||
/// MTConnect Agent's own conventional default.
|
||||
/// <para>
|
||||
/// <b>It caps subscription throughput</b> at <c>SampleCount / SampleIntervalMs</c> observations per
|
||||
/// millisecond (default 1000 a second): a machine producing more falls behind the Agent's ring buffer. The
|
||||
/// pump measures that lag and, when it is persistently behind, reopens the stream with the count doubled — up
|
||||
/// to 16× this value, at most 65 536 — and reports the effective count and the lag on the <c>/hosts</c>
|
||||
/// diagnostics (review 2026-10-21 T-5). Each new stream starts again from this value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int SampleCount { get; init; } = 1000;
|
||||
|
||||
|
||||
@@ -26,27 +26,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </param>
|
||||
/// <param name="MtCategory">
|
||||
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
|
||||
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
|
||||
/// the value-mapping path. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtType">
|
||||
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtSubType">
|
||||
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="Units">
|
||||
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
|
||||
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
|
||||
/// <c>CONDITION</c>. Read by the write path (a CONDITION is never writable). Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Review 2026-10-21 T-3: the <c>MtType</c> / <c>MtSubType</c> / <c>Units</c> members were carried here and
|
||||
/// read by nothing — deleted. A stored blob or <c>tags[]</c> entry that still carries <c>mtType</c> /
|
||||
/// <c>mtSubType</c> / <c>units</c> keeps loading (unknown JSON keys are skipped); the AdminUI tag editor reads
|
||||
/// those keys straight off the blob for its display and type-inference advisory, not through this record.
|
||||
/// </remarks>
|
||||
public sealed record MTConnectTagDefinition(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray = false,
|
||||
int ArrayDim = 0,
|
||||
string? MtCategory = null,
|
||||
string? MtType = null,
|
||||
string? MtSubType = null,
|
||||
string? Units = null);
|
||||
string? MtCategory = null);
|
||||
|
||||
+1
-10
@@ -87,10 +87,7 @@ public static class MTConnectTagDefinitionFactory
|
||||
parsed,
|
||||
blob.IsArray ?? false,
|
||||
blob.ArrayDim ?? 0,
|
||||
blob.MtCategory,
|
||||
blob.MtType,
|
||||
blob.MtSubType,
|
||||
blob.Units);
|
||||
blob.MtCategory);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -264,11 +261,5 @@ public static class MTConnectTagDefinitionFactory
|
||||
public int? ArrayDim { get; init; }
|
||||
/// <summary>The MTConnect DataItem category.</summary>
|
||||
public string? MtCategory { get; init; }
|
||||
/// <summary>The MTConnect DataItem type.</summary>
|
||||
public string? MtType { get; init; }
|
||||
/// <summary>The MTConnect DataItem sub-type.</summary>
|
||||
public string? MtSubType { get; init; }
|
||||
/// <summary>The engineering units, when authored.</summary>
|
||||
public string? Units { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
|
||||
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
|
||||
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
|
||||
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
|
||||
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
|
||||
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync(long, CancellationToken)"/> enumerator
|
||||
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
|
||||
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
|
||||
/// into an ordinary <see cref="OperationCanceledException"/>.
|
||||
@@ -76,10 +76,26 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <returns>The parsed device hierarchy.</returns>
|
||||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// A connectivity check: does the Agent answer <c>/probe</c> with a success status? Reads the response
|
||||
/// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity
|
||||
/// probe used to parse the whole <c>/probe</c> document every tick and throw it away). Throws exactly when
|
||||
/// <see cref="ProbeAsync"/> would for an unreachable or refusing Agent. The default delegates to
|
||||
/// <see cref="ProbeAsync"/> and discards the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Trade-off (review m5): a success status from something that is NOT an Agent — a proxy serving an HTML page
|
||||
/// under 200 — now reads as reachable, where <see cref="ProbeAsync"/> would have failed to parse it. The data
|
||||
/// paths still report that misconfiguration (the read / stream fail to parse).
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token (the caller's deadline).</param>
|
||||
/// <returns>A task completing when the Agent has answered with a success status.</returns>
|
||||
async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
|
||||
/// data item. Used both to prime the observation index at startup and to re-baseline after
|
||||
/// a detected sequence gap in <see cref="SampleAsync"/>.
|
||||
/// a detected sequence gap in <see cref="SampleAsync(long, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The latest observation for every data item.</returns>
|
||||
@@ -113,6 +129,20 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <returns>An asynchronous sequence of streams results, one per multipart chunk.</returns>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="SampleAsync(long, CancellationToken)"/>, but asking the Agent for up to
|
||||
/// <paramref name="count"/> observations per chunk instead of the configured
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> — how the pump catches up when it falls behind an Agent
|
||||
/// producing more than <c>SampleCount / SampleIntervalMs</c> observations a second (R06:T-5). The default
|
||||
/// ignores <paramref name="count"/>, so a client that cannot honour it keeps working at the configured one.
|
||||
/// </summary>
|
||||
/// <param name="from">The sequence to open the stream at.</param>
|
||||
/// <param name="count">The per-chunk observation cap for this stream.</param>
|
||||
/// <param name="ct">Cancellation token; the only contracted way the enumeration ends quietly.</param>
|
||||
/// <returns>The stream's chunks.</returns>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct) =>
|
||||
SampleAsync(from, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
|
||||
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
|
||||
@@ -174,6 +204,34 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets several observations on ONE device in one request — the Agent's PUT surface takes any number of
|
||||
/// <c>{dataItemId}={value}</c> query pairs (<c>POST {agent}/{device}?a=1&b=2</c>) — so a multi-write
|
||||
/// batch costs one round trip per device instead of one per slot (R06:T-6). Same answer contract as
|
||||
/// <see cref="WriteObservationAsync"/>: one outcome for the whole request, an Agent refusal is a value, an
|
||||
/// unreachable Agent throws. The ids must be distinct (a repeated query key's meaning is the Agent's choice).
|
||||
/// The default issues the single-observation call per pair and reports the first refusal.
|
||||
/// </summary>
|
||||
/// <param name="deviceName">The device that owns every DataItem in <paramref name="observations"/>.</param>
|
||||
/// <param name="observations">The <c>(dataItemId, value)</c> pairs, unescaped, ids distinct.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What the Agent said about the request.</returns>
|
||||
async Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observations);
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
var outcome = await WriteObservationAsync(deviceName, dataItemId, value, cancellationToken).ConfigureAwait(false);
|
||||
if (!outcome.Accepted)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return new MTConnectWriteOutcome(Accepted: true, AgentError: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
||||
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||
@@ -183,7 +241,7 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync(long, CancellationToken)"/> only
|
||||
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
|
||||
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
|
||||
/// against the original <c>from</c> instead reports a gap on every chunk once the
|
||||
@@ -199,7 +257,7 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
|
||||
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
|
||||
/// document served under <b>HTTP 200</b>, which this client surfaces as an
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync(long, CancellationToken)"/>, never as a chunk.
|
||||
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
|
||||
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
|
||||
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
|
||||
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
|
||||
/// so a throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync(long, CancellationToken)"/> likewise dials
|
||||
/// nothing until it is enumerated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Disposal is safe against an in-flight enumeration.</b> Re-initialization disposes and rebuilds this client, which can happen while a pump is still
|
||||
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
|
||||
/// enumerating <see cref="SampleAsync(long, CancellationToken)"/>. Rather than letting the in-flight read fault with
|
||||
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
|
||||
/// caller is written to expect, every request runs under a token linked to an internal
|
||||
/// dispose token, so disposal surfaces as an ordinary
|
||||
@@ -79,6 +79,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
private readonly string _agentRoot;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly int _sampleCount;
|
||||
private readonly int _heartbeatMs;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
|
||||
@@ -151,9 +153,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
||||
_sampleQuerySuffix = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
|
||||
// count is appended per request: the driver may raise it for one stream when it falls behind (R06:T-5).
|
||||
_sampleQuerySuffix = string.Create(CultureInfo.InvariantCulture, $"&interval={options.SampleIntervalMs}");
|
||||
_sampleCount = options.SampleCount;
|
||||
_heartbeatMs = options.HeartbeatMs;
|
||||
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
_http.Timeout = _requestTimeout;
|
||||
@@ -179,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return MTConnectProbeParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
// ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model —
|
||||
// the largest document the Agent serves) is never buffered or parsed; disposing the response drops it.
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(_probeUri);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -195,10 +227,32 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
await foreach (var chunk in SampleAsync(from, count: null, ct).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(count), count, "a /sample count must be positive");
|
||||
}
|
||||
|
||||
return SampleAsync(from, (int?)count, ct);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
int? count,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
var uri = BuildSampleUri(from);
|
||||
var uri = BuildSampleUri(from, count);
|
||||
|
||||
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
|
||||
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
|
||||
@@ -226,7 +280,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_ = MTConnectStreamsParser.Parse(single);
|
||||
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
|
||||
@@ -239,7 +293,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
||||
live.ThrowIfCancellationRequested();
|
||||
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, MTConnectUriRedaction.Redact(uri), delivered);
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
@@ -258,8 +312,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
return WriteObservationsAsync(deviceName, [new KeyValuePair<string, string>(dataItemId, value)], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
// Issued on _http, the unary client — the write is a short request/response
|
||||
// and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on
|
||||
@@ -268,10 +332,19 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// watchdog protects a multipart body, not a one-shot POST).
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentNullException.ThrowIfNull(observations);
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("at least one observation is required", nameof(observations));
|
||||
}
|
||||
|
||||
var uri = BuildWriteUri(deviceName, dataItemId, value);
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId, nameof(observations));
|
||||
ArgumentNullException.ThrowIfNull(value, nameof(observations));
|
||||
}
|
||||
|
||||
var uri = BuildWriteUri(deviceName, observations);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
@@ -326,10 +399,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
|
||||
/// contract for why escaping cannot be the caller's job.
|
||||
/// </summary>
|
||||
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
|
||||
private Uri BuildWriteUri(string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations)
|
||||
{
|
||||
var device = Uri.EscapeDataString(deviceName.Trim());
|
||||
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
|
||||
var query = string.Join(
|
||||
"&", observations.Select(o => $"{Uri.EscapeDataString(o.Key)}={Uri.EscapeDataString(o.Value)}"));
|
||||
|
||||
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
|
||||
}
|
||||
@@ -411,8 +485,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private Uri BuildSampleUri(long from) =>
|
||||
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
|
||||
private Uri BuildSampleUri(long from, int? count) =>
|
||||
new(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{_sampleUriBase}?from={from}{_sampleQuerySuffix}&count={count ?? _sampleCount}&heartbeat={_heartbeatMs}"),
|
||||
UriKind.Absolute);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
@@ -484,7 +560,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
private TimeoutException TimedOut(Uri uri) =>
|
||||
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
new($"MTConnect Agent request to '{MTConnectUriRedaction.Redact(uri)}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
|
||||
/// <summary>
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
@@ -513,7 +589,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
@@ -556,7 +632,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{MTConnectUriRedaction.Redact(agentUri)}' is not an absolute http(s) URI.", nameof(options));
|
||||
}
|
||||
|
||||
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
@@ -692,7 +768,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
logger?.LogWarning(
|
||||
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
|
||||
uri);
|
||||
MTConnectUriRedaction.Redact(uri));
|
||||
}
|
||||
|
||||
private byte[]? Finish(MTConnectStreamEndReason reason)
|
||||
@@ -861,7 +937,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (_buffer.Length >= MaxBufferBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
}
|
||||
|
||||
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
|
||||
@@ -878,7 +954,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput is capped at
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> observations per chunk, one chunk per
|
||||
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/>. A machine producing more than that falls steadily behind
|
||||
/// the Agent's ring buffer: every buffer-length the driver re-baselines (losing every intermediate transition) and
|
||||
/// every delivered value is backlog-stale. This half measures the lag, surfaces it, and raises the per-stream
|
||||
/// <c>count</c> so the pump can catch up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Measured per chunk.</b> <c>lag = lastSequence + 1 − nextSequence</c> — the observations the Agent holds
|
||||
/// that this stream has not been sent yet (unknown when the Agent omits <c>lastSequence</c>). A chunk is
|
||||
/// <b>saturated</b> when it carries the full <c>count</c>: the Agent had more and stopped. A stream is behind
|
||||
/// when <see cref="BehindChunksBeforeRaise"/> consecutive chunks are saturated with a positive (or unknown) lag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Adapted per pump.</b> A behind stream is reopened at the cursor it reached with <c>count</c> doubled, up
|
||||
/// to <see cref="SampleCountCeiling"/>. A raised stream that then delivers nothing (an Agent that refuses the
|
||||
/// larger count) steps back to the last count that delivered and stops raising for that pump. At the ceiling,
|
||||
/// a stream still behind logs one Warning per pump. A new pump (a re-initialize, a resubscribe) starts again
|
||||
/// from the configured <see cref="MTConnectDriverOptions.SampleCount"/> — the count is an upper bound, so a
|
||||
/// raised one costs nothing while the Agent has little to send.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Surfaced</b> on the <see cref="IDriverDiagnostics"/> bag the <c>/hosts</c> page shows: the effective
|
||||
/// count, the last measured lag, saturated chunks and count raises.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
{
|
||||
/// <summary>Consecutive behind chunks before the count is raised — one saturated chunk is just a burst.</summary>
|
||||
internal const int BehindChunksBeforeRaise = 3;
|
||||
|
||||
/// <summary>The most the per-stream count is ever raised to, relative to the configured one.</summary>
|
||||
internal const int SampleCountMaxGrowth = 16;
|
||||
|
||||
/// <summary>Absolute ceiling on a raised count (a stock cppagent buffer holds 131072 observations).</summary>
|
||||
internal const int SampleCountAbsoluteCeiling = 65_536;
|
||||
|
||||
/// <summary>The count the current pump is streaming with. Diagnostics only.</summary>
|
||||
private int _effectiveSampleCount;
|
||||
|
||||
/// <summary>The last measured lag (observations), or -1 when unknown. Diagnostics only.</summary>
|
||||
private long _sampleLag = -1;
|
||||
|
||||
/// <summary>Chunks that carried the full count. Diagnostics only.</summary>
|
||||
private long _saturatedChunks;
|
||||
|
||||
/// <summary>Times a pump raised its count. Diagnostics only.</summary>
|
||||
private long _sampleCountRaises;
|
||||
|
||||
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
|
||||
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
|
||||
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
|
||||
internal static int SampleCountCeiling(int configured) =>
|
||||
Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling));
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics()
|
||||
{
|
||||
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var count = Volatile.Read(ref _effectiveSampleCount);
|
||||
if (count > 0)
|
||||
{
|
||||
bag["sampleCount"] = count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var lag = Interlocked.Read(ref _sampleLag);
|
||||
if (lag >= 0)
|
||||
{
|
||||
bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture);
|
||||
bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
return bag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures one delivered chunk against the count its stream was opened with and records the result.
|
||||
/// </summary>
|
||||
/// <param name="chunk">The chunk just applied.</param>
|
||||
/// <param name="sampleCount">The count the stream was opened with.</param>
|
||||
/// <returns>Whether this chunk says the stream is behind the Agent.</returns>
|
||||
private bool MeasureSampleLag(MTConnectStreamsResult chunk, int sampleCount)
|
||||
{
|
||||
long? lag = chunk.LastSequence is { } last ? Math.Max(0, last + 1 - chunk.NextSequence) : null;
|
||||
Interlocked.Exchange(ref _sampleLag, lag ?? -1);
|
||||
|
||||
var saturated = chunk.Observations.Count >= sampleCount;
|
||||
if (saturated)
|
||||
{
|
||||
Interlocked.Increment(ref _saturatedChunks);
|
||||
}
|
||||
|
||||
return saturated && lag is not 0;
|
||||
}
|
||||
|
||||
/// <summary>One pump's adaptive-count state. Only that pump's task touches it.</summary>
|
||||
private sealed class SampleCountState(int configured)
|
||||
{
|
||||
/// <summary>The count the next stream opens with.</summary>
|
||||
public int Count { get; set; } = configured;
|
||||
|
||||
/// <summary>The last count a stream actually delivered at.</summary>
|
||||
public int LastDelivered { get; set; } = configured;
|
||||
|
||||
/// <summary>The most <see cref="Count"/> may be raised to; lowered to the last good count on a refusal.</summary>
|
||||
public int Ceiling { get; set; } = SampleCountCeiling(configured);
|
||||
|
||||
/// <summary>Whether the at-the-ceiling Warning has been logged for this pump.</summary>
|
||||
public bool CeilingWarned { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The count a behind stream should reopen with, or <see langword="null"/> when it is already at its ceiling
|
||||
/// (logged once per pump).
|
||||
/// </summary>
|
||||
private int? NextSampleCount(SampleCountState state, MTConnectDriverOptions options, long cursor)
|
||||
{
|
||||
var sampleCount = state.Count;
|
||||
var next = (int)Math.Min((long)sampleCount * 2, state.Ceiling);
|
||||
if (next > sampleCount)
|
||||
{
|
||||
Interlocked.Increment(ref _sampleCountRaises);
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} is falling behind {AgentUri}'s /sample stream (chunks full at count {Count}, lag {Lag} observation(s)); reopening at sequence {Cursor} with count {NextCount}.",
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag(), cursor, next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!state.CeilingWarned)
|
||||
{
|
||||
state.CeilingWarned = true;
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} is still behind {AgentUri}'s /sample stream at its count ceiling {Count} (lag {Lag} observation(s)): the machine produces more than count / SampleIntervalMs observations a second, so values are backlog-stale and the stream will re-baseline (dropping intermediate transitions) whenever it falls out of the Agent's buffer. Lower SampleIntervalMs or raise SampleCount.",
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string DescribeLag()
|
||||
{
|
||||
var lag = Interlocked.Read(ref _sampleLag);
|
||||
return lag >= 0 ? lag.ToString(CultureInfo.InvariantCulture) : "unknown";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ public sealed record MTConnectComponent(
|
||||
/// <summary>
|
||||
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
|
||||
/// it carries no observed value; values arrive separately via
|
||||
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
|
||||
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>
|
||||
/// and are correlated back to a data item by <see cref="Id"/>.
|
||||
/// </summary>
|
||||
/// <param name="Id">
|
||||
@@ -89,7 +89,7 @@ public sealed record MTConnectDataItem(
|
||||
/// <summary>
|
||||
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
|
||||
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">
|
||||
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
|
||||
@@ -98,7 +98,7 @@ public sealed record MTConnectDataItem(
|
||||
/// </param>
|
||||
/// <param name="NextSequence">
|
||||
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> call <c>from</c>.
|
||||
/// </param>
|
||||
/// <param name="FirstSequence">
|
||||
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
|
||||
@@ -110,11 +110,17 @@ public sealed record MTConnectDataItem(
|
||||
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
|
||||
/// empty when the chunk carries no new observations (e.g. a heartbeat).
|
||||
/// </param>
|
||||
/// <param name="LastSequence">
|
||||
/// The header <c>lastSequence</c> — the newest sequence number the Agent holds, or <c>null</c> when the
|
||||
/// document omits it. With <see cref="NextSequence"/> it measures how far a <c>/sample</c> consumer is behind
|
||||
/// the Agent: <c>LastSequence + 1 - NextSequence</c> observations it has not been sent yet (R06:T-5).
|
||||
/// </param>
|
||||
public sealed record MTConnectStreamsResult(
|
||||
long InstanceId,
|
||||
long NextSequence,
|
||||
long FirstSequence,
|
||||
IReadOnlyList<MTConnectObservation> Observations);
|
||||
IReadOnlyList<MTConnectObservation> Observations,
|
||||
long? LastSequence = null);
|
||||
|
||||
/// <summary>
|
||||
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
|
||||
@@ -162,11 +168,6 @@ public sealed record MTConnectStreamsResult(
|
||||
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
|
||||
/// vector claims to carry. The index checks the parsed vector against it. <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="SampleRate">
|
||||
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
|
||||
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
||||
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="Entries">
|
||||
/// The parsed <c><Entry key="…"></c> children of a <c>DATA_SET</c> / <c>TABLE</c> observation, in
|
||||
/// document order, or <c>null</c> when the observation had none. A <c>TABLE</c> entry carries
|
||||
@@ -195,7 +196,6 @@ public sealed record MTConnectObservation(
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false,
|
||||
int? SampleCount = null,
|
||||
double? SampleRate = null,
|
||||
MTConnectConditionDetail? Condition = null,
|
||||
IReadOnlyList<MTConnectDataSetEntry>? Entries = null,
|
||||
bool ResetTriggered = false,
|
||||
|
||||
@@ -151,7 +151,34 @@ internal sealed class MTConnectObservationIndex
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tags = FreezeTags(tags);
|
||||
}
|
||||
|
||||
/// <summary>Builds an index over an already-frozen tag map (see <see cref="FreezeTags"/>).</summary>
|
||||
private MTConnectObservationIndex(FrozenDictionary<string, MTConnectTagDefinition> frozenTags, TimeProvider? timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tags = frozenTags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R06:T-4 — an index over a tag map frozen ONCE (per binding) rather than per construction: the
|
||||
/// <c>/current</c> read fallback builds a fresh index per batch, and re-freezing a 2–5k-entry tag table on
|
||||
/// every read was a measurable share of that batch.
|
||||
/// </summary>
|
||||
/// <param name="frozenTags">A map produced by <see cref="FreezeTags"/>.</param>
|
||||
/// <returns>A fresh, empty index coercing against <paramref name="frozenTags"/>.</returns>
|
||||
internal static MTConnectObservationIndex OverFrozenTags(FrozenDictionary<string, MTConnectTagDefinition> frozenTags) =>
|
||||
new(frozenTags, null);
|
||||
|
||||
/// <summary>
|
||||
/// The authored tags keyed by <see cref="MTConnectTagDefinition.FullName"/>, last definition winning on a
|
||||
/// duplicate (see the constructor), blank / null entries skipped.
|
||||
/// </summary>
|
||||
/// <param name="tags">The authored tags.</param>
|
||||
/// <returns>The frozen map an index coerces against.</returns>
|
||||
internal static FrozenDictionary<string, MTConnectTagDefinition> FreezeTags(IEnumerable<MTConnectTagDefinition>? tags)
|
||||
{
|
||||
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
||||
foreach (var tag in tags ?? [])
|
||||
{
|
||||
@@ -160,7 +187,7 @@ internal sealed class MTConnectObservationIndex
|
||||
map[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
|
||||
|
||||
@@ -118,7 +118,7 @@ internal static class MTConnectProbeParser
|
||||
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
|
||||
{
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
|
||||
throw MTConnectXml.UnexpectedRoot(root, "MTConnectDevices", Subject);
|
||||
}
|
||||
|
||||
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> is contracted to yield indefinitely until
|
||||
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
|
||||
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
|
||||
/// an enumerator that ends immediately. Returning normally would make "the data path
|
||||
@@ -159,3 +159,4 @@ public sealed class MTConnectStreamNotSupportedException : MTConnectStreamExcept
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,8 +295,7 @@ internal static class MTConnectStreamsParser
|
||||
// exactly this shape (Agents serve one under HTTP 200), and its <Error> elements — the
|
||||
// Agent's own account of what it objected to — are the most useful thing in the whole
|
||||
// response. Materialising the root is how the shared extraction can reach them.
|
||||
throw new InvalidDataException(
|
||||
MTConnectXml.DescribeUnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject));
|
||||
throw MTConnectXml.UnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject);
|
||||
}
|
||||
|
||||
var observations = new List<MTConnectObservation>();
|
||||
@@ -330,7 +329,8 @@ internal static class MTConnectStreamsParser
|
||||
sequences.InstanceId,
|
||||
sequences.NextSequence,
|
||||
sequences.FirstSequence,
|
||||
observations);
|
||||
observations,
|
||||
sequences.LastSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -342,7 +342,14 @@ internal static class MTConnectStreamsParser
|
||||
new(
|
||||
MTConnectXml.RequiredLongAttribute(reader, "instanceId", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(reader, "nextSequence", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject));
|
||||
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject),
|
||||
// Optional on purpose: only the /sample lag measurement reads it, and a document without it must
|
||||
// still parse (the pump then falls back to chunk saturation alone — R06:T-5).
|
||||
long.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "lastSequence"),
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var last) ? last : null);
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the element the reader is positioned on, collecting any observation it is and
|
||||
@@ -383,7 +390,6 @@ internal static class MTConnectStreamsParser
|
||||
var timestampUtc = default(DateTime);
|
||||
string? conditionState = null;
|
||||
int? sampleCount = null;
|
||||
double? sampleRate = null;
|
||||
var resetTriggered = false;
|
||||
var hasCount = false;
|
||||
List<MTConnectDataSetEntry>? entries = null;
|
||||
@@ -421,11 +427,6 @@ internal static class MTConnectStreamsParser
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var count) && count >= 0 ? count : null;
|
||||
sampleRate = double.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "sampleRate"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var rate) && double.IsFinite(rate) ? rate : null;
|
||||
}
|
||||
|
||||
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
|
||||
@@ -485,7 +486,6 @@ internal static class MTConnectStreamsParser
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && hasElements,
|
||||
SampleCount: sampleCount,
|
||||
SampleRate: sampleRate,
|
||||
Condition: condition,
|
||||
Entries: entries is { Count: > 0 } ? entries : null,
|
||||
|
||||
@@ -660,7 +660,7 @@ internal static class MTConnectStreamsParser
|
||||
}
|
||||
|
||||
/// <summary>The three sequence numbers read off a <c><Header></c>.</summary>
|
||||
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence);
|
||||
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence, long? LastSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates one observation's text across however many nodes the parser hands it, without
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
|
||||
/// <c>DriverHealth.LastError</c> and <c>HostName</c> this driver produces. An <c>AgentUri</c> may carry
|
||||
/// <c>user:pass@</c>; the Test-Connect probe already redacted it (<c>MTConnectDriverProbe.RedactedDisplay</c>)
|
||||
/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A URI with no user-info (and, for the configured string, no query or fragment) is returned <b>unchanged</b>,
|
||||
/// so the common case — and every host key derived from it — is byte-identical to what it was. One that does
|
||||
/// carry user-info loses it (and, for the configured string, its query / fragment, which a reverse proxy may use
|
||||
/// for a token). A configured string that is not an absolute URI but contains an <c>@</c> is replaced wholesale:
|
||||
/// there is no reliable way to find the credential in it.
|
||||
/// </remarks>
|
||||
internal static class MTConnectUriRedaction
|
||||
{
|
||||
/// <summary>Placeholder for a configured value that cannot be parsed and might carry a credential.</summary>
|
||||
internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
|
||||
|
||||
/// <summary>The display form of a configured <c>AgentUri</c>.</summary>
|
||||
/// <param name="agentUri">The configured value.</param>
|
||||
/// <returns>The value with any user-info (and query / fragment) removed.</returns>
|
||||
public static string Redact(string? agentUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentUri))
|
||||
{
|
||||
return agentUri ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(agentUri.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return agentUri.Contains('@', StringComparison.Ordinal) ? Unparseable : agentUri;
|
||||
}
|
||||
|
||||
if (uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0)
|
||||
{
|
||||
return agentUri;
|
||||
}
|
||||
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
var path = uri.AbsolutePath == "/" ? string.Empty : uri.AbsolutePath;
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{path}";
|
||||
}
|
||||
|
||||
/// <summary>The display form of a request URI built from the <c>AgentUri</c> (its query is the request's own).</summary>
|
||||
/// <param name="uri">The request URI.</param>
|
||||
/// <returns>The URI as text, without user-info.</returns>
|
||||
public static string Redact(Uri uri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
if (!uri.IsAbsoluteUri || uri.UserInfo.Length == 0)
|
||||
{
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString();
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,44 @@ internal static class MTConnectXml
|
||||
/// <param name="root">The document's actual root element.</param>
|
||||
/// <param name="expectedRootName">The root element name the caller required.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <returns>The error message describing the unexpected root.</returns>
|
||||
/// <returns>
|
||||
/// The exception to throw. For an <c>MTConnectError</c> root (the Agent REFUSED the request) it is marked
|
||||
/// with <see cref="AgentRefusalDataKey"/> — see <see cref="IsAgentRefusal"/>.
|
||||
/// </returns>
|
||||
public static InvalidDataException UnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var ex = new InvalidDataException(DescribeUnexpectedRoot(root, expectedRootName, subject));
|
||||
|
||||
// Marked (InvalidDataException is sealed, so not a subtype) so the read path can tell a refusal — the
|
||||
// Agent answered — from an answer it could not parse at all. Every other handler sees the same exception.
|
||||
if (IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
ex.Data[AgentRefusalDataKey] = true;
|
||||
}
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
/// <summary><see cref="Exception.Data"/> key marking an <c>MTConnectError</c> answer (see <see cref="IsAgentRefusal"/>).</summary>
|
||||
internal const string AgentRefusalDataKey = "MTConnect.AgentRefusal";
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="ex"/> is the Agent answering with an <c>MTConnectError</c> document (e.g.
|
||||
/// <c>NO_DEVICE</c>, <c>INVALID_REQUEST</c>) — a refusal, as opposed to an answer that could not be parsed.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception a parse raised.</param>
|
||||
/// <returns><see langword="true"/> for a refusal.</returns>
|
||||
public static bool IsAgentRefusal(Exception ex) =>
|
||||
ex is InvalidDataException && ex.Data[AgentRefusalDataKey] is true;
|
||||
|
||||
/// <summary>
|
||||
/// The text of <see cref="UnexpectedRoot"/>: the unexpected root, plus the Agent's own error text for an
|
||||
/// <c>MTConnectError</c> document.
|
||||
/// </summary>
|
||||
/// <param name="root">The document's root element.</param>
|
||||
/// <param name="expectedRootName">The root the caller expected.</param>
|
||||
/// <param name="subject">How to describe the document.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var message =
|
||||
|
||||
+63
-3
@@ -17,6 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
private readonly NodeId _rootNodeId;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _disposed;
|
||||
private readonly IDisposable? _ownedCredential;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
||||
@@ -25,8 +26,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
||||
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
||||
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
|
||||
/// <param name="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
|
||||
{
|
||||
_ownedCredential = ownedCredential;
|
||||
_session = session;
|
||||
_nsMap = nsMap;
|
||||
_rootNodeId = rootNodeId;
|
||||
@@ -54,10 +57,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
// R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
|
||||
// maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
|
||||
// side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
|
||||
// Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
|
||||
throw new ArgumentException(
|
||||
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
|
||||
|
||||
uint[] attributeIds =
|
||||
[
|
||||
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
||||
Attributes.UserAccessLevel,
|
||||
];
|
||||
var toRead = new ReadValueIdCollection(attributeIds.Length);
|
||||
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
||||
|
||||
DataValueCollection values;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var resp = await _session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Neither,
|
||||
nodesToRead: toRead,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
values = resp?.Results ?? [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
|
||||
|
||||
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
||||
? values[i] : null;
|
||||
|
||||
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
||||
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
||||
|
||||
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
||||
var dataType = At(2)?.Value is NodeId dt
|
||||
? UpstreamAttributeMapping.MapDataType(dt)
|
||||
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
||||
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
||||
var access = At(4)?.Value is byte ab ? ab : (byte)0;
|
||||
|
||||
return
|
||||
[
|
||||
new AttributeInfo(
|
||||
Name: name,
|
||||
DriverDataType: dataType.ToString(),
|
||||
IsArray: UpstreamAttributeMapping.IsArray(valueRank),
|
||||
SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
||||
@@ -188,5 +245,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
|
||||
try { _ownedCredential?.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
+96
-54
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
};
|
||||
|
||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||
private readonly ISecretResolver? _secretResolver;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
||||
/// <param name="secretResolver">
|
||||
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
|
||||
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
|
||||
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
|
||||
/// </param>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
||||
_secretResolver = secretResolver;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -58,58 +66,69 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
||||
|
||||
if (opts.AuthType == OpcUaAuthType.Certificate)
|
||||
throw new InvalidOperationException(
|
||||
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
|
||||
|
||||
if (opts.AutoAcceptCertificates)
|
||||
_logger.LogWarning(
|
||||
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
||||
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var identity = BuildBrowseUserIdentity(opts);
|
||||
|
||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(perEndpointBudget);
|
||||
|
||||
var endpointDesc = await SelectEndpointAsync(
|
||||
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
||||
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
||||
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
||||
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
||||
// with its own message.
|
||||
var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
||||
if (opts.AutoAcceptCertificates)
|
||||
_logger.LogWarning(
|
||||
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
||||
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
|
||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(perEndpointBudget);
|
||||
|
||||
var endpointDesc = await SelectEndpointAsync(
|
||||
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
||||
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
||||
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||
|
||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedCertificate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
try { session.Dispose(); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
try { session.Dispose(); } catch { /* best-effort */ }
|
||||
// Review m9: the certificate (and its private-key handle) is owned by the browse session once handed
|
||||
// over; any failure before that releases it here instead of leaving it to the GC.
|
||||
ownedCertificate?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -175,16 +194,39 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
||||
/// <summary>
|
||||
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
|
||||
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <returns>The identity the browse session activates with.</returns>
|
||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) =>
|
||||
(await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="BuildBrowseUserIdentityAsync"/>, also handing back the user certificate a Certificate-auth
|
||||
/// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker
|
||||
/// session, and an undisposed <c>X509Certificate2</c> pins a private-key handle until GC).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <returns>The identity, and the certificate it owns (null for Anonymous / Username).</returns>
|
||||
internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)>
|
||||
BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
opts.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||
if (resolved.AuthType != OpcUaAuthType.Certificate)
|
||||
{
|
||||
return (OpcUaUserIdentityFactory.Build(resolved), null);
|
||||
}
|
||||
|
||||
var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved);
|
||||
return (new UserIdentity(certificate), certificate);
|
||||
}
|
||||
|
||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — shared by
|
||||
/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
|
||||
/// browsable with exactly the credentials the deployed driver will present. Includes the <c>secret:</c>
|
||||
/// resolution of the two credential fields (fail-closed).
|
||||
/// </summary>
|
||||
public static class OpcUaUserIdentityFactory
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Build the user-identity token from options whose credential fields are already resolved plaintext
|
||||
/// (see <see cref="ResolveSecretRefsAsync"/>).
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing authentication configuration.</param>
|
||||
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
||||
public static UserIdentity Build(OpcUaClientDriverOptions options) =>
|
||||
options.AuthType switch
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
options.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
||||
OpcUaAuthType.Certificate => FromCertificate(options),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
|
||||
/// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||||
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options));
|
||||
|
||||
/// <summary>
|
||||
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
|
||||
/// the returned certificate — dispose it when the session that presented it is gone.
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||
/// <returns>The certificate, with its private key.</returns>
|
||||
public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(OpcUaClientDriverOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
if (!File.Exists(options.UserCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
options.UserCertificatePath);
|
||||
|
||||
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
||||
// since the legacy X509Certificate2 ctors are marked obsolete on net10.
|
||||
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||
|
||||
if (!cert.HasPrivateKey)
|
||||
{
|
||||
cert.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
||||
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
||||
}
|
||||
|
||||
return cert;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
|
||||
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
|
||||
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
|
||||
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
|
||||
"available here (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
|
||||
/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
|
||||
/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
|
||||
/// </summary>
|
||||
public static class UpstreamAttributeMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
|
||||
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
|
||||
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
|
||||
/// Variant-wrapped structs + enums + extension objects.
|
||||
/// </summary>
|
||||
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
||||
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
||||
public static DriverDataType MapDataType(NodeId dataType)
|
||||
{
|
||||
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
|
||||
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
|
||||
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
|
||||
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
|
||||
// confuses range/validation logic keyed off DriverDataType).
|
||||
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
|
||||
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
|
||||
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
|
||||
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
|
||||
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
|
||||
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
|
||||
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
|
||||
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
|
||||
if (dataType == DataTypeIds.String) return DriverDataType.String;
|
||||
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
|
||||
return DriverDataType.DateTime;
|
||||
return DriverDataType.String;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> bitmask) to a
|
||||
/// <see cref="SecurityClassification"/>: CurrentWrite-capable variables surface as
|
||||
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
||||
/// <returns>The mapped security classification.</returns>
|
||||
public static SecurityClassification MapAccessLevel(byte accessLevel)
|
||||
{
|
||||
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
||||
return (accessLevel & CurrentWrite) != 0
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
|
||||
/// >1 N dimensions. Anything >= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
|
||||
/// carries a concrete dimensioned rank.
|
||||
/// </summary>
|
||||
/// <param name="valueRank">The ValueRank attribute.</param>
|
||||
/// <returns>Whether the variable is exposed as an array.</returns>
|
||||
public static bool IsArray(int valueRank) => valueRank >= 0;
|
||||
}
|
||||
+7
-2
@@ -11,8 +11,9 @@
|
||||
</PropertyGroup>
|
||||
<!--
|
||||
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
|
||||
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
|
||||
NamespaceMap.
|
||||
browser both need that is typed in ISession / NodeId / NamespaceTable / UserIdentity: NamespaceMap,
|
||||
UpstreamAttributeMapping (DataType/AccessLevel/ValueRank -> driver shape) and OpcUaUserIdentityFactory
|
||||
(credential secret: resolution + the SDK UserIdentity, incl. certificate auth).
|
||||
|
||||
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
|
||||
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
|
||||
@@ -35,6 +36,10 @@
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
|
||||
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
|
||||
credentials the deployed driver will. Abstractions only (one interface + a name type). -->
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,8 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
|
||||
// Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a
|
||||
// stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block
|
||||
// the probe when the caller passes CancellationToken.None.
|
||||
// the probe when the caller passes CancellationToken.None. The config's ProbeTimeoutSeconds caps it (O-4).
|
||||
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
@@ -106,6 +107,23 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's
|
||||
/// <see cref="OpcUaClientDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (it can only
|
||||
/// shorten the budget — the caller's own deadline fires regardless). A non-positive caller budget is floored
|
||||
/// to 1 s so the probe runs briefly rather than cancelling before it starts. Same rule as the Galaxy probe.
|
||||
/// </summary>
|
||||
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
||||
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
|
||||
/// <returns>The effective probe deadline.</returns>
|
||||
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
||||
{
|
||||
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
||||
if (configuredSeconds <= 0) return caller;
|
||||
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
||||
return configured < caller ? configured : caller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
|
||||
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
|
||||
|
||||
@@ -21,8 +21,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </remarks>
|
||||
internal static class OpcUaClientSecretResolution
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
@@ -37,54 +35,12 @@ internal static class OpcUaClientSecretResolution
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||
/// <param name="resolver">The shared secret resolver.</param>
|
||||
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
<div class="form-text">Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state tables and — when this node
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state + comment tables and — when this node
|
||||
/// actually replicates — opts them into change capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
public static class LocalDbSetup
|
||||
{
|
||||
/// <summary>
|
||||
/// The four tables that participate in replication — registered when this node is configured
|
||||
/// The five tables that participate in replication — registered when this node is configured
|
||||
/// to replicate, and deregistered (stale capture triggers dropped) when it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -36,6 +36,7 @@ public static class LocalDbSetup
|
||||
DeploymentCacheSchema.PointerTable,
|
||||
AlarmSfSchema.EventsTable,
|
||||
AlarmConditionStateSchema.StateTable,
|
||||
AlarmConditionStateSchema.CommentsTable,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -47,8 +48,9 @@ public static class LocalDbSetup
|
||||
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
|
||||
/// changes into the oplog. Any row written before that call is never captured, so it
|
||||
/// never reaches the peer — silently, and permanently, because nothing ever revisits
|
||||
/// history. <see cref="AlarmSfLegacyMigrator"/> therefore runs <b>last</b>, after every
|
||||
/// registration — it writes rows, and they must be captured like any other write.
|
||||
/// history. <see cref="AlarmConditionStateSchema.MigrateInlineComments"/> and
|
||||
/// <see cref="AlarmSfLegacyMigrator"/> therefore run <b>last</b>, after every
|
||||
/// registration — they write rows, and those must be captured like any other write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Registration is CONDITIONAL on <see cref="ReplicationIsConfigured"/>.</b> It used
|
||||
@@ -68,7 +70,7 @@ public static class LocalDbSetup
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure part-way through either loop fails host start, and that is deliberate.</b>
|
||||
/// Neither branch catches, so a throw on the third of four tables leaves two flipped and
|
||||
/// Neither branch catches, so a throw on the third of five tables leaves two flipped and
|
||||
/// propagates out of the <c>AddZbLocalDb</c> singleton factory — a node that came up
|
||||
/// anyway would be capturing (or not) on an arbitrary subset, which is silently partial
|
||||
/// replication with nothing in the log to say so. Both library calls are idempotent, so
|
||||
@@ -116,7 +118,36 @@ public static class LocalDbSetup
|
||||
else
|
||||
RemoveStaleCapture(db, logger ?? Serilog.Log.Logger);
|
||||
|
||||
// LAST, and only here. This is the one call in OnReady that writes rows.
|
||||
// LAST, and only here. These are the only calls in OnReady that write rows.
|
||||
// Copies any pre-split inline comment trail (alarm_condition_state.comments_json) into the
|
||||
// append-only alarm_condition_comment table (review R05:4). Idempotent: an ordinal already
|
||||
// held is never rewritten, and the condition row itself is never touched (the alarm's next
|
||||
// save clears the column). An unreadable trail is reported, not skipped silently.
|
||||
AlarmConditionStateSchema.InlineCommentMigrationResult commentMigration;
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
commentMigration = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
||||
}
|
||||
|
||||
var migrationLog = logger ?? Serilog.Log.Logger;
|
||||
if (commentMigration.RowsMoved > 0)
|
||||
{
|
||||
migrationLog.Information(
|
||||
"LocalDb: copied the inline comment trail of {AlarmCount} scripted alarm(s) into {Table}.",
|
||||
commentMigration.RowsMoved,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
foreach (var alarmId in commentMigration.UnparseableAlarmIds)
|
||||
{
|
||||
migrationLog.Warning(
|
||||
"LocalDb: scripted alarm {AlarmId} has an unreadable inline comment trail "
|
||||
+ "(alarm_condition_state.comments_json is not valid JSON); its history was NOT moved to {Table} "
|
||||
+ "and the alarm's state will not load until the row is repaired.",
|
||||
alarmId,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
AlarmSfLegacyMigrator.Migrate(db, configuration);
|
||||
}
|
||||
|
||||
|
||||
+296
-98
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
@@ -11,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
|
||||
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit trail + operator comments.
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> and
|
||||
/// <see cref="AlarmConditionStateSchema.CommentsTable"/> tables in the node's consolidated
|
||||
/// LocalDb. Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit fields on the condition row, and the append-only comment
|
||||
/// trail one row per entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -24,44 +24,75 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
/// state then mirrors to its redundant pair peer instead of depending on central SQL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The round-trip is byte-identical to the EF store</b> — the enum↔string mappers and the
|
||||
/// comment serialization below are duplicated verbatim from it (it is deleted in a later
|
||||
/// task; the ported round-trip tests prove parity). The persistence rules carry over
|
||||
/// unchanged:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
|
||||
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
|
||||
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
|
||||
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
|
||||
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
|
||||
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
|
||||
/// have no columns and default to <c>null</c> on load.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is a single unconditional PK upsert.</b> No application-level last-write-wins or
|
||||
/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
|
||||
/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
|
||||
/// alarm-sf sink and the deployment pointer follow).
|
||||
/// <b>The comment trail is written incrementally (review R05:4).</b> Each save writes only
|
||||
/// the entries the table does not yet hold, so a transition's write — and its replication
|
||||
/// payload — is constant however long the history is; the condition row's
|
||||
/// <c>comments_json</c> is always written as <c>"[]"</c>. What is "already stored" is read
|
||||
/// from the table inside the save's transaction (a bounded read of the trail's tail) rather
|
||||
/// than cached, so the store holds no per-alarm state. The pair peer's copy of the same
|
||||
/// command is recognised and not duplicated; a local entry that collides with a peer entry
|
||||
/// this node never applied is appended after it with a Warning, never dropped — see
|
||||
/// <c>AppendNewCommentsAsync</c> and, for why the ordinal is the key,
|
||||
/// <see cref="AlarmConditionStateSchema"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Remove vs. a lagging peer.</b> <see cref="RemoveAsync"/> deletes the whole trail; if the
|
||||
/// pair peer has not yet applied the same deploy, its next save finds no stored trail and
|
||||
/// re-inserts every entry, which replicates back. The next deploy removes them again — the
|
||||
/// same resurrection class the single-row store had, with n+1 rows instead of one (review m3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Old-shape rows load exactly as before.</b> A row still carrying an inline
|
||||
/// <c>comments_json</c> trail (written by the pre-split store and not yet moved by
|
||||
/// <see cref="AlarmConditionStateSchema.MigrateInlineComments"/>) is read through the same
|
||||
/// parser the old store used, merged by ordinal with whatever the comment table holds (the
|
||||
/// table wins an ordinal both hold), and moved to the table on that alarm's next save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is one transaction:</b> the comment append and an unconditional PK upsert of the
|
||||
/// condition row. No application-level last-write-wins or HLC comparison is layered on top —
|
||||
/// the LocalDb replication HLC handles convergence.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Fixed column order shared by every read — the reader map below indexes by these ordinals.
|
||||
private const string SelectColumns =
|
||||
"scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
|
||||
"shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
|
||||
"last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
|
||||
|
||||
private const string SelectCommentColumns = "scripted_alarm_id, seq, timestamp_utc, user_name, kind, text";
|
||||
|
||||
/// <summary>
|
||||
/// How many trailing in-memory entries each save reconciles against the stored trail by
|
||||
/// content. The engine appends at most one entry per save, so this comfortably covers a few
|
||||
/// missed commands while keeping each save's read bounded.
|
||||
/// </summary>
|
||||
private const int MatchWindow = 16;
|
||||
|
||||
/// <summary>
|
||||
/// How far apart two nodes' timestamps for the SAME command may be and still count as one
|
||||
/// entry: command fan-out latency plus inter-node clock skew. Beyond it both copies are kept
|
||||
/// (the safe direction — a duplicate, never a loss).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SameCommandSkew = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ILocalDb _db;
|
||||
private readonly ILogger<LocalDbAlarmConditionStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
|
||||
/// <param name="db">
|
||||
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
|
||||
/// must already exist and be registered for replication — the host does both in
|
||||
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
|
||||
/// The node's local database. Its condition-state and comment tables must already exist and
|
||||
/// be registered for replication — the host does both in <c>LocalDbSetup.OnReady</c>, before
|
||||
/// any consumer can resolve this store.
|
||||
/// </param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
|
||||
@@ -79,7 +110,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
return rows.Count > 0 ? rows[0] : null;
|
||||
if (rows.Count == 0) return null;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return WithComments(rows[0], comments);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,7 +129,19 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
return rows;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment ORDER BY scripted_alarm_id, seq",
|
||||
MapComment,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
var byAlarm = comments
|
||||
.GroupBy(c => c.AlarmId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<StoredComment>)g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
return rows
|
||||
.Select(r => WithComments(r, byAlarm.TryGetValue(r.State.AlarmId, out var c) ? c : []))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -98,10 +149,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await AppendNewCommentsAsync(tx, state, ct).ConfigureAwait(false);
|
||||
|
||||
// A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
|
||||
// last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
|
||||
// app-level LWW here (see the class remarks).
|
||||
await _db.ExecuteAsync(
|
||||
// app-level LWW here (see the class remarks). comments_json is always "[]": the trail lives
|
||||
// in alarm_condition_comment, which is what keeps this row a fixed size.
|
||||
await tx.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
@@ -140,60 +196,243 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
LastConfirmUser = state.LastConfirmUser,
|
||||
LastConfirmComment = state.LastConfirmComment,
|
||||
LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
|
||||
CommentsJson = SerializeComments(state.Comments),
|
||||
CommentsJson = AlarmConditionStateSchema.EmptyInlineComments,
|
||||
// No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
|
||||
UpdatedAtUtc = Format(state.LastTransitionUtc),
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the trail entries <paramref name="state"/> holds that the table does not, writing
|
||||
/// a bounded number of rows however long the trail is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Normal case.</b> The in-memory trail and the stored trail agree position by
|
||||
/// position; the only new entries are past <c>MAX(seq)</c> and land at their own ordinal.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Divergence (review I-1).</b> If this node missed a command its peer applied, the
|
||||
/// peer's entry arrives in this table at an ordinal this node's in-memory trail does not
|
||||
/// know about, and this node's NEXT entry has an in-memory index at or below
|
||||
/// <c>MAX(seq)</c>. Positional "already stored" would silently drop it — the survivor's
|
||||
/// newest operator action, in the failover case. So the tail of the in-memory trail
|
||||
/// (the last <see cref="MatchWindow"/> entries) is matched against the stored rows from
|
||||
/// that position on, by content: same Kind, User and Text, timestamps within
|
||||
/// <see cref="SameCommandSkew"/> (each node stamps the same command with its own clock).
|
||||
/// A matched stored row is consumed, so repeated identical entries are counted, not
|
||||
/// merged. An entry with no match is new: at its own ordinal when that is past the stored
|
||||
/// trail, otherwise appended after it (<c>MAX(seq)+1</c>) with a Warning — kept, never
|
||||
/// dropped, and never silent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this cannot double-count.</b> Both pair nodes apply every command; the second
|
||||
/// node to save finds the first's copy among the stored rows (same Kind/User/Text, close
|
||||
/// timestamp) and consumes it instead of appending. A node's own earlier divergence-append
|
||||
/// is found the same way, so a re-save writes nothing. What it cannot tell apart: a peer
|
||||
/// entry this node missed and a byte-identical entry this node later records within the
|
||||
/// skew window — that pair is kept once. A clock skew between the nodes beyond the window
|
||||
/// errs the other way (both copies kept), never towards loss. True union semantics need an
|
||||
/// originator-assigned entry id on the alarm command (follow-up F3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The in-memory index is the durable ordinal.</b> Trimming
|
||||
/// <see cref="AlarmConditionState.Comments"/> in the engine without carrying a persisted
|
||||
/// base offset would make every later save look "already stored" — do not bound that list
|
||||
/// without changing this method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AppendNewCommentsAsync(ILocalDbTransaction tx, AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
var comments = state.Comments;
|
||||
var count = comments.Count;
|
||||
|
||||
// An indexed MAX over the (scripted_alarm_id, seq) primary key — a read, so no replication cost.
|
||||
var max = (await tx.QueryAsync(
|
||||
"SELECT COALESCE(MAX(seq), -1) FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
static r => r.GetInt64(0),
|
||||
new { Id = state.AlarmId },
|
||||
ct).ConfigureAwait(false))[0];
|
||||
|
||||
var windowStart = Math.Max(0, count - MatchWindow);
|
||||
|
||||
// Entries below the window that lie past the stored trail (a first save of a long trail, an
|
||||
// old-shape row's lazy move): plain appends at their own ordinal — nothing is stored there.
|
||||
for (var seq = (int)Math.Max(max + 1, 0); seq < windowStart; seq++)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, seq, comments[seq], ct).ConfigureAwait(false);
|
||||
max = seq;
|
||||
}
|
||||
|
||||
if (windowStart >= count) return;
|
||||
|
||||
// The stored rows the window can match: ordinal >= windowStart. Bounded by the window plus the
|
||||
// number of commands this node missed.
|
||||
var stored = max >= windowStart
|
||||
? await tx.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id AND seq >= @Lo ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = state.AlarmId, Lo = windowStart },
|
||||
ct).ConfigureAwait(false)
|
||||
: [];
|
||||
var consumed = new bool[stored.Count];
|
||||
|
||||
for (var i = windowStart; i < count; i++)
|
||||
{
|
||||
var entry = comments[i];
|
||||
var match = -1;
|
||||
for (var j = 0; j < stored.Count; j++)
|
||||
{
|
||||
if (!consumed[j] && IsSameEntry(stored[j].Comment, entry))
|
||||
{
|
||||
match = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match >= 0)
|
||||
{
|
||||
consumed[match] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i > max)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, i, entry, ct).ConfigureAwait(false);
|
||||
max = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = max + 1;
|
||||
await InsertCommentAsync(tx, state.AlarmId, target, entry, ct).ConfigureAwait(false);
|
||||
max = target;
|
||||
_logger.LogWarning(
|
||||
"Alarm {AlarmId}: comment trail diverged from the pair peer — the {Kind} entry by {User} at in-memory "
|
||||
+ "position {Position} collides with a stored entry this node never applied (a missed command). "
|
||||
+ "Kept: appended at ordinal {Ordinal}.",
|
||||
state.AlarmId, entry.Kind, entry.User, i, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameEntry(AlarmComment stored, AlarmComment local) =>
|
||||
string.Equals(stored.Kind, local.Kind, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.User, local.User, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.Text, local.Text, StringComparison.Ordinal)
|
||||
&& (stored.TimestampUtc - local.TimestampUtc).Duration() <= SameCommandSkew;
|
||||
|
||||
private static Task<int> InsertCommentAsync(
|
||||
ILocalDbTransaction tx, string alarmId, long seq, AlarmComment c, CancellationToken ct) =>
|
||||
tx.ExecuteAsync(
|
||||
AlarmConditionStateSchema.InsertCommentSql,
|
||||
new
|
||||
{
|
||||
Id = alarmId,
|
||||
Seq = seq,
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for a Utc round-trip; the engine
|
||||
// always creates comments with Utc kind.
|
||||
Ts = AlarmConditionStateSchema.FormatTimestamp(c.TimestampUtc),
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
},
|
||||
ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alarmId);
|
||||
await _db.ExecuteAsync(
|
||||
|
||||
// The trail goes with the condition, as it did when it was a column of the row.
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapRow(SqliteDataReader r) => new(
|
||||
/// <summary>A condition row as read, before its trail is attached.</summary>
|
||||
private sealed record ConditionRow(AlarmConditionState State, string InlineCommentsJson);
|
||||
|
||||
/// <summary>One <c>alarm_condition_comment</c> row.</summary>
|
||||
private sealed record StoredComment(string AlarmId, long Seq, AlarmComment Comment);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the trail: the comment table's entries by ordinal, plus — for an old-shape row not
|
||||
/// yet migrated — any inline entry at an ordinal the table does not hold.
|
||||
/// </summary>
|
||||
private static AlarmConditionState WithComments(ConditionRow row, IReadOnlyList<StoredComment> stored)
|
||||
{
|
||||
var inline = AlarmConditionStateSchema.ParseInlineComments(row.InlineCommentsJson);
|
||||
if (inline.Count == 0)
|
||||
return row.State with { Comments = stored.Select(s => s.Comment).ToImmutableList() };
|
||||
|
||||
var merged = new SortedDictionary<long, AlarmComment>();
|
||||
foreach (var s in stored)
|
||||
merged[s.Seq] = s.Comment;
|
||||
for (var i = 0; i < inline.Count; i++)
|
||||
{
|
||||
var c = inline[i];
|
||||
merged.TryAdd(i, new AlarmComment(c.TimestampUtc, c.User, c.Kind, c.Text));
|
||||
}
|
||||
|
||||
return row.State with { Comments = merged.Values.ToImmutableList() };
|
||||
}
|
||||
|
||||
private static StoredComment MapComment(SqliteDataReader r) => new(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: DeserializeComments(r.GetString(12)));
|
||||
Seq: r.GetInt64(1),
|
||||
Comment: new AlarmComment(
|
||||
AlarmConditionStateSchema.ParseTimestamp(r.GetString(2)),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetString(5)));
|
||||
|
||||
private static ConditionRow MapRow(SqliteDataReader r) => new(
|
||||
new AlarmConditionState(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: ImmutableList<AlarmComment>.Empty),
|
||||
InlineCommentsJson: r.GetString(12));
|
||||
|
||||
// Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
|
||||
// the DateTimeKind.Utc round-trips exactly.
|
||||
private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string Format(DateTime value) => AlarmConditionStateSchema.FormatTimestamp(value);
|
||||
|
||||
private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string? FormatNullable(DateTime? value) => value is { } v ? Format(v) : null;
|
||||
|
||||
private static DateTime Parse(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
private static DateTime Parse(string value) => AlarmConditionStateSchema.ParseTimestamp(value);
|
||||
|
||||
private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
|
||||
r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
|
||||
@@ -220,45 +459,4 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
{
|
||||
if (comments.IsEmpty) return "[]";
|
||||
var dtos = comments.Select(c => new CommentDto
|
||||
{
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
|
||||
// the engine always creates AlarmComment instances with Utc kind.
|
||||
TimestampUtc = c.TimestampUtc,
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
});
|
||||
return JsonSerializer.Serialize(dtos, JsonOptions);
|
||||
}
|
||||
|
||||
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
|
||||
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
|
||||
return dtos
|
||||
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
|
||||
private sealed class CommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded (UTC).</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor that wrote the comment.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied or engine-generated comment text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user