Compare commits

..

2 Commits

Author SHA1 Message Date
Joseph Doherty e77c8a3569 feat(drivers): tag-set drift detection, gated on SupportsOnlineDiscovery
The second thing #516/§8.2 deliberately did not build. The original objection
stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
DiscoverAsync, so diffing discovered against authored is a tautology for them
— but it is now ENCODED rather than used as a reason not to build.

The discriminator already existed: ITagDiscovery.SupportsOnlineDiscovery is
documented as "enumerates the tag set from the live backend rather than
replaying pre-declared/authored tags", and it separates the fleet cleanly —
FOCAS, TwinCAT, MTConnect and AbCip true; the five config-echo drivers
explicitly false. The check runs only for a driver that is
SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is
nullable rather than empty-by-default on purpose: an empty collection
legitimately means "nothing authored, everything discovered is new", so
conflating the two would report total drift for a driver that never opted in.

Where the value is: AbCip and FOCAS browse a live backend but implement no
IRediscoverable, so before this they had no change signal at all. A periodic
compare is the only way to notice a PLC re-download.

A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their
authored tags into the same DiscoverAsync stream as device-derived ones, so
the browse picker can show an operator their existing tags. Discovered is
therefore always a superset of authored, and a VANISHED tag can never appear
missing — one whole direction is structurally undetectable for three of the
four. Shipping a Vanished list that is permanently empty for them would have
been the half-inert seam this whole exercise removes. So the driver declares
DiscoveryStreamIncludesAuthoredTags, the detector does not compute the
undetectable half, and the operator message says "missing-tag detection
unavailable for this driver" rather than letting the absence of a missing-tag
clause read as reassurance. MTConnect is the only driver where both directions
work — its stream is purely probe-model-derived.

Behaviour: a failed browse is not drift (an unreachable device would otherwise
report every authored tag as vanished); a truncated capture is skipped for the
same reason; an unchanged drift is reported once rather than re-stamping its
timestamp; drift that resolves clears the prompt. Interval defaults to 5
minutes — it browses a real device, and a tag set changing is an engineering
event, not a runtime one. It reuses the Stage-2 surfacing, raising the same
/hosts re-browse prompt, and never rebuilds the served address space.

Comparison logic is a pure, separately-tested type rather than actor-inline.
14 new tests. The gate was verified load-bearing by deleting the
SupportsOnlineDiscovery half: the tautology-driver test goes red, and it
asserts discovery was never invoked rather than merely "no prompt raised",
which would have passed for the wrong reason if the timer never fired.

Full suite green except the same three pre-existing fixture-gated integration
suites, identical counts.
2026-07-28 00:48:39 -04:00
Joseph Doherty 5184a2e107 fix(drivers): complete the Sql + FOCAS in-place config re-parse (#516)
The first #516 pass deliberately left these two out, because each derives more
than options from config: Sql its ISqlDialect and resolved connection string,
FOCAS the IFocasClientFactory its Backend key selects — all injected at
construction. Adopting new options alone would poll a NEW tag set through the
OLD database/backend, and a half-applied config change is worse than a
discarded one because it looks like it worked.

Rather than accept that exclusion, this fixes the blocker. Each factory now
exposes a ParseBinding returning EVERY config-derived dependency as one value,
and the driver adopts them atomically:

- Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds
  everything downstream — provider factory, Endpoint, and the SqlPollReader
  that captures all three. It runs BEFORE BuildTagTable, so the tag table and
  the connection it is polled over always come from the same revision. A
  test-injected DbProviderFactory survives a rebind (_explicitFactory), so a
  re-derived dialect cannot displace what a test passed in.
- FOCAS: options and backend move as a pair in InitializeAsync.

Re-resolving Sql's connection string on reinit is a side benefit: a rotated
credential is picked up without a process restart.

Drivers constructed directly get no rebinder and keep their constructor
binding, so every existing "{}"-passing lifecycle test is unaffected by
construction rather than by luck.

The tests pin ATOMICITY, not merely that a re-parse happened — a test checking
only options would have passed against the broken version. Confirmed by
simulating the half-fix (adopt options, skip the backend): 2 of 3 FOCAS tests
go red. Reverting Sql's rebind turns its connection-string test red. Sql also
asserts that a reinit which cannot resolve its new connection leaves the
previous binding whole rather than half-adopting.

All 12 drivers now honour a changed config in place. Sql.Tests 226, FOCAS.Tests
275 pass.
2026-07-28 00:30:52 -04:00
16 changed files with 1237 additions and 69 deletions
+25 -4
View File
@@ -188,10 +188,31 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
the actor.
**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql
all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is
permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the
trustworthy signal because the driver asserts it deliberately.
**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
only way to notice a PLC re-download.
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
legitimately means "nothing authored, so everything discovered is new".
⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
reports only what it can see and **says so** in the operator message rather than implying a clean bill of
health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
built purely from the Agent's probe model.
Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
+74
View File
@@ -497,6 +497,80 @@ describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in
in-process when its managed wire client landed). No runtime change — turning recycle on for two live
drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
more than options from config, so adopting new options alone would run a **new tag set against an old
connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
| Driver | What one parse now yields | Adopted by |
|---|---|---|
| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
Details worth keeping:
- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
come from the same revision.
- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
cannot silently displace what a test passed in.
- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
without a process restart.
- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
half-adopting.
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
driver that simply never opted in).
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
detection is complementary there.
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
driver where both directions work** — its stream is purely probe-model-derived.
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
engineering event, not a runtime one.
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
@@ -38,4 +38,32 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
/// </summary>
bool SupportsOnlineDiscovery => false;
/// <summary>
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
/// two sets detects that the remote's tag set has drifted from what an operator authored.
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
/// authored refs are directly comparable to what it discovers.</para>
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
/// replays authored tags from config rather than browsing the backend, the comparison is a
/// tautology — it would report "no drift" forever and read as reassurance.</para>
/// </summary>
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
/// <summary>
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
/// shows an operator their existing tags alongside what the device offers.
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
/// as reassurance.</para>
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
/// from the Agent's probe model — so both directions are detectable.</para>
/// </summary>
bool DiscoveryStreamIncludesAuthoredTags => false;
}
@@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
/// controller-browse leaves also use.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_declaredTags.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
/// pre-declared tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
private FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
private IFocasClientFactory _clientFactory;
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
/// was constructed directly (tests, or a caller supplying its own backend), which keeps the
/// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
private readonly Func<string, FocasDriverBinding>? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
@@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
/// <param name="logger">Optional logger instance.</param>
/// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
/// supplied by the factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct
/// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
/// options + backend.</param>
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null,
ILogger<FocasDriver>? logger = null)
ILogger<FocasDriver>? logger = null,
Func<string, FocasDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_rebind = rebind;
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
backoffCap: PollBackoffCap);
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
/// without a config — those keep the constructor-supplied options + backend.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -102,13 +127,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Opens the configured CNC handles and builds the authored tag table.
/// <para><paramref name="driverConfigJson"/> is deliberately <b>not</b> re-parsed here, unlike
/// Modbus / AbLegacy / OpcUaClient (Gitea #516). This driver builds more than options from config —
/// its <c>IFocasClientFactory</c> is selected from the <c>Backend</c> key and injected at
/// construction — so adopting a new <c>FocasDriverOptions</c> alone would run a NEW device/tag set
/// against the OLD backend. Half a re-parse is worse than none.</para>
/// <para>A config change is covered by the stop + respawn in <c>DriverSpawnPlanner</c>, which
/// rebuilds options and backend together through the factory.</para>
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
/// #516 pass.</para>
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
/// remains the outer guarantee.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
@@ -118,6 +145,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
try
{
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
// parse and assigned together — a partial adoption would poll a new device/tag set through the
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
_options = binding.Options;
_clientFactory = binding.ClientFactory;
}
// Fail fast if the factory is a stub/unimplemented backend — the operator must
// see an actionable error at init rather than a phantom-Healthy driver that fails
// every read/write/subscribe silently.
@@ -479,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
var binding = ParseBinding(driverInstanceId, driverConfigJson);
return new FocasDriver(
binding.Options,
driverInstanceId,
binding.ClientFactory,
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
// config-derived dependency on a reinitialize — options AND the backend client factory
// together (#516). Re-parsing options alone would run a new device/tag set against the old
// backend, which is worse than not re-parsing at all.
rebind: json => ParseBinding(driverInstanceId, json));
}
/// <summary>
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
/// <see cref="CreateInstance"/>.
/// </summary>
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>The parsed options + backend factory.</returns>
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
};
var clientFactory = BuildClientFactory(dto, driverInstanceId);
return new FocasDriver(options, driverInstanceId, clientFactory);
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
}
/// <summary>
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? Interval { get; init; }
}
}
/// <summary>
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
@@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </remarks>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The DataItem ids the authored raw tags bind. <c>DiscoverAsync</c> emits
/// <c>FullName: dataItem.Id</c> from the Agent's probe model, so the two sets are the same
/// vocabulary.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
/// <inheritdoc />
/// <remarks>False — <c>DiscoverAsync</c> is built purely from the Agent's probe model and never
/// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
/// missing. MTConnect is currently the only driver where both drift directions are detectable.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => false;
/// <inheritdoc/>
/// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
@@ -1,4 +1,5 @@
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -51,16 +52,19 @@ public sealed class SqlDriver
// ---- instance fields (grouped at top for auditability) ----
private readonly SqlDriverOptions _options;
/// <summary>Config-derived, so mutable — see <see cref="ApplyBinding"/>.</summary>
private SqlDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ISqlDialect _dialect;
private readonly DbProviderFactory _factory;
/// <summary>Config-derived, so mutable: <see cref="ApplyBinding"/> swaps it with the connection string
/// and options together on a reinitialize (#516).</summary>
private ISqlDialect _dialect;
private DbProviderFactory _factory;
/// <summary>
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
/// </summary>
private readonly string _connectionString;
private string _connectionString;
private readonly ILogger<SqlDriver> _logger;
@@ -93,11 +97,19 @@ public sealed class SqlDriver
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary>The factory the CALLER passed, or null to take the dialect's. Kept separate from
/// <see cref="_factory"/> so a re-derived dialect cannot silently displace a test's injected factory.</summary>
private readonly DbProviderFactory? _explicitFactory;
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
/// was constructed directly, which keeps the constructor-supplied trio authoritative.</summary>
private readonly Func<string, SqlDriverBinding>? _rebind;
/// <summary>
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
/// delegate must read this driver's live authored table, which does not exist before the driver does.
/// </summary>
private readonly SqlPollReader _reader;
private SqlPollReader _reader;
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
private readonly PollGroupEngine _poll;
@@ -133,6 +145,11 @@ public sealed class SqlDriver
/// explicitly by tests that need to observe or delay connection creation.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <param name="rebind">
/// Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency, supplied by the
/// factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct construction —
/// every test) keeps the constructor-supplied options, dialect and connection string.
/// </param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
public SqlDriver(
@@ -141,7 +158,8 @@ public sealed class SqlDriver
ISqlDialect dialect,
string connectionString,
DbProviderFactory? factory = null,
ILogger<SqlDriver>? logger = null)
ILogger<SqlDriver>? logger = null,
Func<string, SqlDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(dialect);
@@ -149,15 +167,47 @@ public sealed class SqlDriver
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
_options = options;
_driverInstanceId = driverInstanceId;
_dialect = dialect;
_factory = factory ?? dialect.Factory;
_connectionString = connectionString;
_logger = logger ?? NullLogger<SqlDriver>.Instance;
Endpoint = DescribeEndpoint(connectionString);
_explicitFactory = factory;
_rebind = rebind;
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
// Everything below this line is derived from config and is re-derived wholesale on a reinitialize.
ApplyBinding(options, dialect, connectionString);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <summary>
/// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything
/// downstream of it (the provider factory, the endpoint description, and the poll reader, which
/// captures all three). Called from the constructor and again from <see cref="InitializeAsync"/>
/// when a changed config arrives.
/// <para><b>All-or-nothing on purpose.</b> The reason Sql was excluded from the first #516 pass is
/// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing
/// it in one method means a future field derived from config gets added here rather than being
/// forgotten on the reinit path.</para>
/// <para>Not thread-safe by design: only the init path calls it, before any poll group is running.
/// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to
/// <c>ReadAsync</c> and the resolver reads the live tag table, so neither captures the old trio.</para>
/// </summary>
/// <param name="options">The typed options to adopt.</param>
/// <param name="dialect">The provider seam to adopt.</param>
/// <param name="connectionString">The resolved connection string to adopt. Never logged.</param>
[MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))]
private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString)
{
_options = options;
_dialect = dialect;
_factory = _explicitFactory ?? dialect.Factory;
_connectionString = connectionString;
Endpoint = DescribeEndpoint(connectionString);
_reader = new SqlPollReader(
_factory,
connectionString,
@@ -168,12 +218,16 @@ public sealed class SqlDriver
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
logger: _logger);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
/// without a config — those keep the constructor-supplied trio.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
@@ -187,7 +241,7 @@ public sealed class SqlDriver
/// the connection string names both. This is the only rendering of the connection string that may
/// appear in a log, a health message, or the Admin UI.
/// </summary>
public string Endpoint { get; }
public string Endpoint { get; private set; } = "";
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
public string HostName => Endpoint;
@@ -215,17 +269,20 @@ public sealed class SqlDriver
/// <summary>
/// Builds the authored RawPath table and proves the database is reachable.
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
/// typed <see cref="SqlDriverOptions"/> it was constructed with. That premise — "config parsing
/// belongs to the factory, which builds a fresh instance" — was <b>false when written</b>
/// (Gitea #516): the host reinitialized the EXISTING child in place, so every config edit was
/// silently discarded and the deployment still sealed green. It is true <i>now</i>, because
/// <c>DriverSpawnPlanner</c> routes a changed <c>DriverConfig</c> through a stop + respawn.</para>
/// <para>This driver deliberately does <b>not</b> also re-parse in place, unlike Modbus / AbLegacy /
/// OpcUaClient. It builds more than options from config — the <see cref="ISqlDialect"/> and the
/// resolved connection string are both injected at construction — so adopting a new
/// <see cref="SqlDriverOptions"/> alone would run a NEW tag set against the OLD connection. Half a
/// re-parse is worse than none; the respawn rebuilds all three together.</para>
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise
/// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when
/// written, because the host reinitialized the EXISTING child in place and every config edit was
/// silently discarded while the deployment still sealed green.</para>
/// <para>The re-parse is <b>all-or-nothing</b> via <see cref="ApplyBinding"/>: options, the
/// <see cref="ISqlDialect"/> and the resolved connection string are re-derived by one
/// <c>ParseBinding</c> call and adopted together. That is what makes it safe here — adopting options
/// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded
/// from the first #516 pass. It happens BEFORE <c>BuildTagTable</c>, so the tag table and the
/// connection it is polled over always come from the same revision.</para>
/// <para>A driver constructed directly (every unit test) gets no rebinder and keeps its
/// constructor-supplied trio, so <c>"{}"</c>-passing lifecycle tests are unaffected.
/// <c>DriverSpawnPlanner</c>'s stop + respawn remains the outer guarantee.</para>
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
@@ -243,6 +300,18 @@ public sealed class SqlDriver
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
// #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
}
BuildTagTable();
try
@@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>
/// Parses a Sql <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
/// from config: the typed options, the <see cref="ISqlDialect"/> the <c>provider</c> key selects, and
/// the connection string resolved from <c>connectionStringRef</c>.
/// <para>Returned together because they must be adopted together. Re-parsing options alone on a
/// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a
/// half-applied config change is worse than a discarded one, because it looks like it worked.</para>
/// <para>Re-running this on reinitialize also re-resolves the connection string from its environment
/// variable, so a rotated credential is picked up without a process restart.</para>
/// </summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <returns>The parsed options, dialect and resolved connection string.</returns>
/// <exception cref="InvalidOperationException">The config is malformed or names no connection string.</exception>
internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
// that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
}
/// <summary>
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
@@ -102,25 +139,11 @@ public static class SqlDriverFactoryExtensions
public static SqlDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
// secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
var options = BuildOptions(dto, driverInstanceId);
var binding = ParseBinding(driverInstanceId, driverConfigJson);
var dto = binding.Dto;
var dialect = binding.Dialect;
var connectionString = binding.ConnectionString;
var options = binding.Options;
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
@@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions
dialect,
connectionString,
factory: null,
logger: loggerFactory?.CreateLogger<SqlDriver>());
logger: loggerFactory?.CreateLogger<SqlDriver>(),
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options,
// dialect and connection string together on a reinitialize (#516). Re-resolving the connection
// string also picks up a rotated credential without a process restart.
rebind: json => ParseBinding(driverInstanceId, json));
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
// permitted outside the provider call.
@@ -250,3 +277,21 @@ public static class SqlDriverFactoryExtensions
}
}
}
/// <summary>
/// Everything <c>SqlDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// <para>Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
/// connection string are both config-derived, so adopting new <see cref="Options"/> alone would poll a
/// NEW tag set against the OLD database (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
public sealed record SqlDriverBinding(
SqlDriverOptions Options,
ISqlDialect Dialect,
string ConnectionString,
SqlDriverConfigDto Dto);
@@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> is its RawPath and its driver FullName
/// (<c>DiscoverAsync</c> emits <c>FullName: tag.Name</c>), directly comparable to the browsed
/// symbols' <c>InstancePath</c>.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed ADS symbols AND re-emits every authored tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -1,5 +1,6 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -32,6 +33,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
@@ -121,6 +127,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.</summary>
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
/// <see cref="QualifiesForDriftCheck"/>.</summary>
private sealed record DriftCheckTick
{
public static readonly DriftCheckTick Instance = new();
private DriftCheckTick() { }
}
public sealed class RetryConnect
{
public static readonly RetryConnect Instance = new();
@@ -181,6 +196,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason;
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
private readonly TimeSpan _driftCheckInterval;
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
/// which reads as "it just happened again" rather than "it is still true".</summary>
private string? _lastDriftSignature;
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
private IReadOnlyList<string> _desiredRefs = Array.Empty<string>();
@@ -214,6 +237,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
@@ -225,7 +250,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) =>
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
@@ -233,7 +259,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
invoker,
healthPollInterval));
healthPollInterval,
driftCheckInterval));
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -264,6 +291,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor(
@@ -273,13 +302,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null)
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null)
{
_driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -350,6 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDriftCheck();
});
Receive<InitializeFailed>(msg =>
{
@@ -395,6 +427,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driverInstanceId, msg.Reason);
DetachSubscription();
RecordFault();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -402,6 +435,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -424,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms();
});
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
Receive<DataChangeForward>(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -518,6 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDriftCheck();
});
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -762,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
private void StopDriftCheck() => Timers.Cancel("drift-check");
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
@@ -809,6 +849,119 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoveryHandler = null;
}
/// <summary>
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
/// coverage.</para>
/// </summary>
private bool QualifiesForDriftCheck() =>
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
private void StartDriftCheck()
{
if (!QualifiesForDriftCheck()) return;
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
// report phantom drift on every reconnect.
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
}
/// <summary>
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
/// compare is the only way to notice a PLC program re-download.
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
/// </summary>
private async Task HandleDriftCheckAsync(DriftCheckTick _)
{
if (_driver is not ITagDiscovery discovery) return;
var authored = discovery.AuthoredDiscoveryRefs;
if (authored is null) return;
CapturedTree tree;
try
{
var builder = new CapturingAddressSpaceBuilder();
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
// block writes, reconnects and health polls behind it.
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
await _invoker.ExecuteAsync(
DriverCapability.Discover,
_driverInstanceId,
async ct => await discovery.DiscoverAsync(builder, ct),
cts.Token);
tree = builder.Build();
}
catch (Exception ex)
{
// A failed browse is not drift — the device may simply be unreachable, which the health surface
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
return;
}
if (tree.Truncated)
{
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
_log.Warning(
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
_driverInstanceId);
return;
}
var discovered = new List<string>();
CollectLeafRefs(tree.Root, discovered);
var drift = TagSetDriftDetector.Compare(
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
if (!drift.HasDrift)
{
// Recovered: the device matches the authored set again, so drop the prompt and let the next
// genuine drift re-raise with a fresh timestamp.
if (_lastDriftSignature is not null)
{
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
_driverInstanceId);
_lastDriftSignature = null;
_rediscoveryNeededUtc = null;
_rediscoveryReason = null;
PublishHealthSnapshot();
}
return;
}
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
// as "it just happened again" rather than "it is still true".
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
_lastDriftSignature = drift.Signature;
_rediscoveryNeededUtc = DateTime.UtcNow;
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
_log.Info(
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
_driverInstanceId, drift.Describe());
PublishHealthSnapshot();
}
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
private static void CollectLeafRefs(CapturedNode node, List<string> into)
{
if (node.IsLeaf) into.Add(node.Id);
foreach (var child in node.Children) CollectLeafRefs(child, into);
}
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -984,6 +1137,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <inheritdoc />
protected override void PostStop()
{
StopDriftCheck();
DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
@@ -0,0 +1,92 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// The difference between what a driver <b>discovers</b> on its remote and what an operator
/// <b>authored</b> — i.e. whether the device's tag set has moved out from under the deployed config.
/// </summary>
/// <param name="Appeared">Refs the remote now offers that no authored tag binds.</param>
/// <param name="Vanished">
/// Refs authored tags bind that the remote no longer offers. <b>Always empty when
/// <paramref name="VanishedDetectable"/> is false</b> — see that parameter.
/// </param>
/// <param name="VanishedDetectable">
/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
/// ones (<c>ITagDiscovery.DiscoveryStreamIncludesAuthoredTags</c>). The discovered set then always
/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
/// <see cref="Describe"/> can say "new-on-device only" rather than implying a clean bill of health.
/// </param>
public sealed record TagSetDrift(
IReadOnlyList<string> Appeared,
IReadOnlyList<string> Vanished,
bool VanishedDetectable)
{
/// <summary>True when the two sets differ in either direction.</summary>
public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
/// <summary>
/// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
/// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
/// drift look new.
/// </summary>
public string Signature { get; } =
string.Join('', Appeared) + '' + string.Join('', Vanished);
/// <summary>An operator-facing one-liner naming what moved, suitable for a UI tooltip.</summary>
/// <returns>A short human-readable description, or "no drift" when there is none.</returns>
public string Describe()
{
if (!HasDrift) return "no drift";
var parts = new List<string>(3);
if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
// Say so rather than let the absence of a "missing" clause read as "nothing is missing".
if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
return string.Join("; ", parts);
}
/// <summary>Names at most two refs so the message stays readable when a whole PLC program is swapped.</summary>
private static string Sample(IReadOnlyList<string> refs) =>
refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
}
/// <summary>
/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
/// actor's periodic check stays cheap; kept out of <c>DriverInstanceActor</c> so it is directly testable
/// without an actor system.
/// </summary>
public static class TagSetDriftDetector
{
/// <summary>
/// Computes the two-way difference.
/// <para><b>Ordinal comparison</b>, matching how every driver keys its tag tables — a device that
/// genuinely exposes both <c>Speed</c> and <c>speed</c> must not have them collapsed.</para>
/// </summary>
/// <param name="discovered">Refs streamed by the driver's most recent discovery pass.</param>
/// <param name="authored">Refs the driver's authored tags bind (<c>ITagDiscovery.AuthoredDiscoveryRefs</c>).</param>
/// <param name="detectVanished">
/// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
/// structurally invisible. The vanished half is then not computed at all rather than computed to a
/// misleading empty — see <see cref="TagSetDrift.VanishedDetectable"/>.
/// </param>
/// <returns>The drift, which may be empty.</returns>
public static TagSetDrift Compare(
IReadOnlyCollection<string> discovered,
IReadOnlyCollection<string> authored,
bool detectVanished = true)
{
ArgumentNullException.ThrowIfNull(discovered);
ArgumentNullException.ThrowIfNull(authored);
var authoredSet = new HashSet<string>(authored, StringComparer.Ordinal);
var discoveredSet = new HashSet<string>(discovered, StringComparer.Ordinal);
var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray();
var vanished = detectVanished
? authoredSet.Where(r => !discoveredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray()
: [];
return new TagSetDrift(appeared, vanished, detectVanished);
}
}
@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config:
/// the <c>Backend</c> key selects its <see cref="IFocasClientFactory"/>, injected at construction. Adopting
/// a new <c>FocasDriverOptions</c> alone would poll a NEW device/tag set through the OLD backend, which
/// looks like it worked and does not.
/// <para><c>ParseBinding</c> now returns options + backend together and <c>InitializeAsync</c> adopts both
/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only
/// checked options would have passed against the broken version.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitConfigAdoptionTests
{
/// <summary>
/// Reinitializing onto <c>Backend: "unimplemented"</c> must make the driver adopt that backend, whose
/// <c>EnsureUsable()</c> throws by design. A driver still holding the constructor's <c>wire</c>
/// backend would not throw — so the throw is a direct read of which backend is live.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"wire","devices":[]}""");
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
"""{"backend":"unimplemented","devices":[]}""", CancellationToken.None));
ex.ShouldBeOfType<NotSupportedException>(
"FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied "
+ "adoption (#516) that would poll a new device/tag set through the old backend");
}
/// <summary>The other direction: moving OFF the unimplemented backend must also take effect, so the
/// adoption is not a one-way latch.</summary>
[Fact]
public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"unimplemented","devices":[]}""");
// Sanity: the constructor-supplied backend is the throwing one.
(await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None)))
.ShouldBeOfType<NotSupportedException>();
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
"""{"backend":"wire","devices":[]}""", CancellationToken.None));
ex.ShouldNotBeOfType<NotSupportedException>(
"FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'");
}
/// <summary>An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests
/// that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"unimplemented","devices":[]}""");
(await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)))
.ShouldBeOfType<NotSupportedException>();
}
}
@@ -0,0 +1,120 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config
/// (its <see cref="ISqlDialect"/> and its resolved connection string are both config-derived), so
/// adopting a new <c>SqlDriverOptions</c> alone would poll a NEW tag set through the OLD database.
/// A half-applied config change is worse than a discarded one, because it looks like it worked.
/// <para>The fix is <c>ParseBinding</c> + <c>ApplyBinding</c>: one parse produces all three, and they are
/// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the
/// property that made this driver special.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class SqlReinitConfigAdoptionTests
{
private const string RefA = "SqlReinitTestA";
private const string RefB = "SqlReinitTestB";
/// <summary>
/// A reinitialize that changes <c>connectionStringRef</c> must move the driver onto the NEW database.
/// <c>Endpoint</c> is the credential-free rendering of the live connection string, so it is a direct
/// read of which connection the driver actually adopted — no log-string matching.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
using var __ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB,
"Server=new-server;Database=NewDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
driver.Endpoint.ShouldContain("old-server");
// The database is unreachable, so init faults — irrelevant here. The assertion is which database it
// tried, i.e. whether the connection string moved with the options.
try
{
await driver.ReinitializeAsync(
$$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None);
}
catch
{
// Liveness failure against a non-existent server is expected.
}
driver.Endpoint.ShouldContain(
"new-server",
customMessage:
"SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact "
+ "half-applied adoption (#516) that would poll a new tag set against the old database");
driver.Endpoint.ShouldNotContain("old-server");
}
/// <summary>
/// A config whose <c>connectionStringRef</c> names an unprovisioned environment variable must make
/// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where
/// new options are live against the old connection.
/// </summary>
[Fact]
public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(
"""{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""",
CancellationToken.None));
driver.Endpoint.ShouldContain(
"old-server",
customMessage:
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
}
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
/// tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
catch { /* liveness failure expected */ }
driver.Endpoint.ShouldContain("old-server");
}
/// <summary>Sets an environment variable for the duration of a test and restores it after. The resolver
/// reads process-global state, so a leaked variable would race every sibling test.</summary>
private sealed class ScopedEnvironmentVariable : IDisposable
{
private readonly string _name;
private readonly string? _previous;
public ScopedEnvironmentVariable(string name, string value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
}
@@ -0,0 +1,268 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored.
/// <para><b>Why this is gated rather than run for every driver.</b> Modbus, S7, AbLegacy, Sql and Mqtt
/// all stream their AUTHORED tags back out of <c>DiscoverAsync</c> rather than enumerating the device, so
/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail
/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that
/// declare <c>SupportsOnlineDiscovery</c> AND report <c>AuthoredDiscoveryRefs</c>.</para>
/// <para>The signal is advisory and shares the Stage-2 surfacing: it raises the same <c>/hosts</c>
/// re-browse prompt an <see cref="IRediscoverable"/> raise does. The served address space is never
/// rebuilt — raw tags are authored through the <c>/raw</c> browse-commit flow.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase
{
private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60);
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3);
/// <summary>A device offering a tag nothing binds raises the operator prompt, naming what appeared.</summary>
[Fact]
public void A_new_ref_on_the_device_raises_the_re_browse_prompt()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() =>
{
var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
signalled.ShouldNotBeNull();
signalled!.RediscoveryReason.ShouldNotBeNull();
signalled.RediscoveryReason!.ShouldContain("1 new on device");
signalled.RediscoveryReason!.ShouldContain("c");
},
Budget);
Sys.Stop(actor);
}
/// <summary>
/// <b>The gate that makes this feature honest.</b> A driver whose <c>DiscoverAsync</c> replays
/// authored config rather than browsing the device declares <c>SupportsOnlineDiscovery == false</c>,
/// and must never be drift-checked — for it the comparison could only ever say "no drift".
/// <para>Positive control: the driver is handed a set that WOULD read as drift, and the assertion is
/// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong
/// reason (e.g. if the timer never fired at all).</para>
/// </summary>
[Fact]
public void A_driver_that_replays_authored_config_is_never_drift_checked()
{
var driver = new BrowsableStubDriver(
discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
// Long enough for several ticks had the timer been scheduled.
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
driver.DiscoverCount.ShouldBe(
0,
"a driver that replays authored config was browsed for drift — the comparison is a tautology "
+ "for it and would report 'no drift' forever, which reads as coverage");
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
Sys.Stop(actor);
}
/// <summary>A driver that browses but does NOT report its authored refs opts out — the default. An empty
/// collection would mean "nothing authored, everything is new", so null must not be read that way.</summary>
[Fact]
public void A_driver_that_reports_no_authored_refs_is_never_drift_checked()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
driver.DiscoverCount.ShouldBe(0);
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
Sys.Stop(actor);
}
/// <summary>
/// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp
/// keeps moving reads as "it just happened again" rather than "it is still true".
/// </summary>
[Fact]
public void An_unchanged_drift_is_reported_once()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1),
Budget);
// Several more ticks pass with the same drift.
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget);
publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(
1,
"an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp");
Sys.Stop(actor);
}
/// <summary>A failed browse is not drift. A device that is merely unreachable already shows on the health
/// surface; reporting drift would turn every comms blip into "all your tags vanished".</summary>
[Fact]
public void A_failed_browse_is_not_reported_as_drift()
{
var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true };
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget);
publisher.Published.ShouldAllBe(
p => p.RediscoveryNeededUtc == null,
"a browse that threw was treated as drift — an unreachable device would report every authored "
+ "tag as vanished");
Sys.Stop(actor);
}
/// <summary>Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next
/// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one.</summary>
[Fact]
public void Drift_that_resolves_clears_the_prompt()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
Budget);
// The operator commits the new tag: authored now matches the device.
driver.SetAuthored(["a", "b"]);
AwaitAssert(
() => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(),
Budget);
Sys.Stop(actor);
}
private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher)
{
var parent = CreateTestProbe();
parent.IgnoreMessages(_ => true);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, driftCheckInterval: Tick));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
return actor;
}
/// <summary>Captured health publishes, so a test can read the rediscovery fields.</summary>
private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason);
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
{
private readonly List<HealthPublish> _published = [];
public IReadOnlyList<HealthPublish> Published
{
get { lock (_published) return _published.ToArray(); }
}
/// <inheritdoc />
public void Publish(
string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min,
DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null)
{
lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason));
}
}
/// <summary>
/// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages —
/// the shared <c>StubDriver</c> returns <c>DateTime.UtcNow</c>, which would make every publish look
/// changed and let a dedup-related bug pass unnoticed.
/// </summary>
private sealed class BrowsableStubDriver(
string[] discovered,
string[]? authored,
bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery
{
private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc);
private volatile string[]? _authored = authored;
private int _discoverCount;
/// <summary>Number of discovery passes the actor has driven — the direct read of whether the drift
/// check ran at all.</summary>
public int DiscoverCount => Volatile.Read(ref _discoverCount);
/// <summary>When true, every browse throws — models an unreachable device.</summary>
public bool DiscoverThrows { get; init; }
/// <inheritdoc />
public string DriverInstanceId => "browsable-stub-1";
/// <inheritdoc />
public string DriverType => "Stub";
/// <inheritdoc />
public bool SupportsOnlineDiscovery => supportsOnlineDiscovery;
/// <inheritdoc />
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs => _authored;
/// <summary>Models the operator committing (or removing) tags between checks.</summary>
public void SetAuthored(string[] refs) => _authored = refs;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _discoverCount);
if (DiscoverThrows) throw new IOException("device unreachable");
var folder = builder.Folder("Stub", "Stub");
foreach (var r in discovered)
{
folder.Variable(r, r, new DriverAttributeInfo(
FullName: r,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,113 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored.
/// Kept out of the actor so the comparison rules are testable without an actor system.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagSetDriftDetectorTests
{
/// <summary>Matching sets are not drift, and must not raise an operator prompt.</summary>
[Fact]
public void Identical_sets_are_not_drift()
{
var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]);
drift.HasDrift.ShouldBeFalse();
drift.Describe().ShouldBe("no drift");
}
/// <summary>A tag the device now offers that nothing binds — the common case after a PLC re-download.</summary>
[Fact]
public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared()
{
var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]);
drift.Appeared.ShouldBe(["c"]);
drift.Vanished.ShouldBeEmpty();
drift.HasDrift.ShouldBeTrue();
}
/// <summary>An authored tag the device no longer offers — detectable only for a driver whose discovery
/// stream is purely device-derived.</summary>
[Fact]
public void An_authored_ref_missing_from_the_device_is_reported_as_vanished()
{
var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]);
drift.Vanished.ShouldBe(["b"]);
drift.Appeared.ShouldBeEmpty();
}
/// <summary>
/// <b>The honesty guard.</b> When a driver re-emits its authored tags into the discovery stream —
/// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a
/// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty
/// Vanished list; it must flag that the direction is undetectable, and say so in the description.
/// </summary>
[Fact]
public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty()
{
// "b" is authored and absent from the device — but this driver would have re-emitted it, so its
// absence here cannot occur in practice and must not be presented as a clean result either way.
var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false);
drift.VanishedDetectable.ShouldBeFalse();
drift.Vanished.ShouldBeEmpty();
drift.Appeared.ShouldBe(["c"]);
drift.Describe().ShouldContain(
"missing-tag detection unavailable",
customMessage:
"a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' "
+ "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes");
}
/// <summary>Comparison is ordinal: a device legitimately exposing both <c>Speed</c> and <c>speed</c>
/// must not have them collapsed, which would hide a genuinely new tag.</summary>
[Fact]
public void Comparison_is_case_sensitive()
{
var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]);
drift.Appeared.ShouldBe(["speed"]);
}
/// <summary>The signature identifies a drift so an UNCHANGED one is not re-announced every check.
/// Enumeration order must not make an identical drift look new.</summary>
[Fact]
public void Signature_is_stable_across_enumeration_order()
{
var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]);
a.Signature.ShouldBe(b.Signature);
}
/// <summary>A different drift must get a different signature, or the second one is never reported.</summary>
[Fact]
public void Signature_changes_when_the_drift_changes()
{
var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]);
var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
a.Signature.ShouldNotBe(b.Signature);
}
/// <summary>The description names a couple of refs and then counts, so swapping a whole PLC program
/// yields a readable message rather than a wall of tag names.</summary>
[Fact]
public void Describe_samples_rather_than_listing_everything()
{
var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray();
var drift = TagSetDriftDetector.Compare(discovered, []);
var text = drift.Describe();
text.ShouldContain("50 new on device");
text.ShouldContain("+48 more");
text.Length.ShouldBeLessThan(120);
}
}