revert(drivers): remove the periodic 5-minute device re-browse (#523)

Removes the tag-set drift detector shipped in e77c8a35. The design was sound
-- the tautology drivers were correctly gated out, and the structurally
undetectable "vanished" direction was declared rather than faked -- but none of
that is the objection. The feature browsed real plant equipment on a recurring
timer, at a frequency no operator could configure (the interval was a
constructor parameter with no appsettings key), and that outbound traffic was
never once observed against a real PLC, CNC or MTConnect Agent.

Removed: TagSetDrift/TagSetDriftDetector; DriverInstanceActor's drift timer,
tick message, Props/ctor parameter, gate, handler and leaf-collector;
ITagDiscovery.AuthoredDiscoveryRefs + .DiscoveryStreamIncludesAuthoredTags and
their four implementations; 14 tests.

Kept: ITagDiscovery.SupportsOnlineDiscovery (it predates this and drives the
universal browse picker), and the whole IRediscoverable consumption from
09a401b8 -- the driver tells us, we do not poll.

CONSEQUENCE, deliberately accepted: AbCip and FOCAS browse a live backend but
implement no IRediscoverable, so they now have NO tag-change signal at all -- a
PLC re-download is invisible until someone re-browses by hand. Recorded in
CLAUDE.md so the next reader does not have to rediscover it, along with the
shape to reach for if that coverage is wanted back (event-driven, or triggered
by a reconnect/deploy, not a wall-clock timer).

Runtime.Tests 476 pass / 13 skipped (skip set unchanged); FOCAS 275, TwinCAT
192, MTConnect 491, AbCip 342, Core.Abstractions 266 all green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-28 13:59:06 -04:00
parent 16752032e5
commit 5fff597324
11 changed files with 59 additions and 743 deletions
+15 -31
View File
@@ -188,38 +188,22 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
the actor. the actor.
**Tag-set drift detection is slated for REMOVAL (Gitea #523).** It is in `master` today and the **`IRediscoverable` is the ONLY tag-change signal, and four drivers do not raise it.** A periodic
description below is accurate, but the periodic device re-browse is being taken back out: it is tag-set drift check briefly existed (shipped `e77c8a35`, **removed** by Gitea **#523**): every
recurring, unconfigurable (interval is a constructor parameter, no appsettings key) and never `SupportsOnlineDiscovery` driver was re-browsed on a 5-minute timer and the result diffed against its
live-gated traffic to real PLCs/CNCs/Agents. After removal, **AbCip and FOCAS have no tag-change signal authored tags. It was taken back out because it generated recurring, **unconfigurable** (interval was a
at all** (they implement no `IRediscoverable`); TwinCAT and MTConnect keep their native ones. The constructor parameter with no appsettings key), **never-live-gated** outbound traffic to real
`IRediscoverable` consumption above is unaffected. PLCs/CNCs/Agents — how often to browse someone's plant equipment is an operational decision, and the
design treated it as an implementation detail.
**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor` ⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but
re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download
against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT,
FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an
only way to notice a PLC re-download. event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a
deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`,
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting `DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo `DiscoveryStreamIncludesAuthoredTags`.
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 ⚠️ **`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 nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
+41 -8
View File
@@ -46,11 +46,11 @@ themes into tracked decisions. What remains:
| **#520** node ACLs authored + deployed but **never enforced** | Decision: wire it or retire the surface. Non-enforcement is now stated in docs and warned in the UI, so nobody can author a deny rule believing it works — but reads are still ungated. | §3.1 | | **#520** node ACLs authored + deployed but **never enforced** | Decision: wire it or retire the surface. Non-enforcement is now stated in docs and warned in the UI, so nobody can author a deny rule believing it works — but reads are still ungated. | §3.1 |
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 | | **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 |
| **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 | | **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 |
| **#523** remove the periodic 5-min device re-browse | **Reversal of work shipped in this remediation.** After removal, AbCip and FOCAS have no tag-change signal at all. | §9 (2026-07-28 entry) | | ~~**#523** remove the periodic 5-min device re-browse~~ | **Done** removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) |
| **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 | | **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 |
| **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 | | **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 | | 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10, and the **un-gated drift detector** (moot if #523 lands). | §5 | | Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
**Three issues are resolved but still open on the tracker****#516** (fixed for all 12 drivers), **Three issues are resolved but still open on the tracker****#516** (fixed for all 12 drivers),
**#518** (the `IRediscoverable` half fixed; the probe half split to #521) and **#507** (injection deleted, **#518** (the `IRediscoverable` half fixed; the probe half split to #521) and **#507** (injection deleted,
@@ -300,7 +300,7 @@ mere env-var presence, so it cannot pass vacuously.
| Gate | Status | Evidence | | Gate | Status | Evidence |
|---|---|---| |---|---|---|
| **Tag-set drift detection** (shipped `e77c8a35`) | **OPEN — and likely moot.** Never live-gated: it needs a device whose tag set actually changes. Gitea **#523** removes the feature, which would close this gate by deletion. | | ~~**Tag-set drift detection** (shipped `e77c8a35`)~~ | ✅ **CLOSED BY DELETION** — removed under Gitea **#523** rather than gated. The gate it needed (a device whose tag set actually changes) required exactly the traffic the removal objected to. | §9, 2026-07-28 (#523) |
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists | | **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse | | **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` | | **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
@@ -470,7 +470,7 @@ the tree.
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `88ce8df0` | | 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `88ce8df0` |
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `88ce8df0` | | 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `88ce8df0` |
| follow-up A | ✅ **Done** — Sql/FOCAS in-place re-parse made **atomic** | `5184a2e1` | | follow-up A | ✅ **Done** — Sql/FOCAS in-place re-parse made **atomic** | `5184a2e1` |
| follow-up B | ⛔ **Shipped, then reverted by decision** — tag-set drift detection; removal tracked as Gitea **#523** | `e77c8a35` | | follow-up B | ⛔ **Shipped, then removed by decision** — tag-set drift detection; **removal landed**, Gitea **#523** | `e77c8a35`, removed below |
### 2026-07-27 — §8.1 ACL non-enforcement made explicit ### 2026-07-27 — §8.1 ACL non-enforcement made explicit
@@ -624,10 +624,9 @@ guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2) ### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
> ⛔ **SUPERSEDED SAME DAY — the periodic re-browse is to be REMOVED (owner decision, Gitea #523).** > ⛔ **REMOVED — the periodic re-browse is gone from the tree (owner decision, Gitea #523; removal
> It ships in `master` (`e77c8a35`, merged `914d70bf`) and this entry stays as the record of what was > recorded in the next entry).** It shipped in `master` (`e77c8a35`, merged `914d70bf`) and this entry
> built and why, but it is **not the intended end state**. The objection is the one this entry does not > stays as the record of what was built and why. The objection is the one this entry does not weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no
> weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no
> appsettings key) and **never live-gated** outbound traffic to real PLCs, CNCs and MTConnect Agents. > appsettings key) and **never live-gated** outbound traffic to real PLCs, CNCs and MTConnect Agents.
> Frequency of an unsolicited device browse is an operational decision, and the design treated it as an > Frequency of an unsolicited device browse is an operational decision, and the design treated it as an
> implementation detail. > implementation detail.
@@ -679,6 +678,40 @@ The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no 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. prompt raised", which would have passed for the wrong reason if the timer simply never fired.
### 2026-07-28 — #523: the periodic re-browse removed
The feature described in the entry above is **gone from the tree**. Removed:
| Removed | Where |
|---|---|
| `TagSetDrift` + `TagSetDriftDetector` | `Runtime/Drivers/TagSetDrift.cs` (file deleted) |
| The drift timer, `DriftCheckTick`, `DefaultDriftCheckInterval`, the `driftCheckInterval` Props/ctor parameter, `QualifiesForDriftCheck`, `Start`/`StopDriftCheck`, `HandleDriftCheckAsync`, `CollectLeafRefs`, `_lastDriftSignature` | `DriverInstanceActor` |
| `ITagDiscovery.AuthoredDiscoveryRefs` + `.DiscoveryStreamIncludesAuthoredTags`, and their four implementations | `Core.Abstractions`; FOCAS, TwinCAT, MTConnect, AbCip |
| `DriverInstanceActorDriftCheckTests` (6), `TagSetDriftDetectorTests` (8) | `Runtime.Tests` |
**What survives, deliberately.** `ITagDiscovery.SupportsOnlineDiscovery` stays — it predates drift
detection and is what the universal browse picker dispatches on. The `IRediscoverable` consumption
(`09a401b8`) is untouched: the driver tells us, we do not poll, and it costs nothing when nothing
changes. The `/hosts` re-browse chip stays and is now fed by that one source.
**The consequence, stated plainly rather than buried.** **AbCip and FOCAS now have no tag-change signal
at all.** Both browse a live backend, neither implements `IRediscoverable`, and the periodic compare was
the only thing that would have noticed a PLC re-download or a CNC option install. That is the accepted
trade, not an oversight — it is recorded in `CLAUDE.md` §Change Detection so the next reader does not
have to rediscover it, together with the shape to reach for if the coverage is wanted back
(event-driven, or triggered by a reconnect/deploy, not a wall-clock timer).
**Why the earlier entry's reasoning did not save it.** That entry defends the *design* — the gate is
real, the tautology drivers are correctly excluded, the undetectable direction is declared rather than
faked. All of that is true and none of it is the objection. The objection is to the feature existing at
all in that shape: it browsed real plant equipment on a recurring timer, at a frequency nobody could
configure, and that behaviour was never once observed against real hardware. A well-built mechanism for
something that should not run is still something that should not run.
Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the stale
`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342,
`Core.Abstractions` 266, all green.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6) ### 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 **The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
@@ -38,32 +38,4 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
/// </summary> /// </summary>
bool SupportsOnlineDiscovery => false; 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,17 +1036,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary> /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true; 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 /> /// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -517,17 +517,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.</summary> /// until the node set is non-empty and stable.</summary>
public bool SupportsOnlineDiscovery => true; 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 /> /// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -907,19 +907,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </remarks> /// </remarks>
public bool SupportsOnlineDiscovery => true; 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/> /// <inheritdoc/>
/// <remarks> /// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the /// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
@@ -404,17 +404,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary> /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true; 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 /> /// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -1,6 +1,5 @@
using Akka.Actor; using Akka.Actor;
using Akka.Event; using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -33,11 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{ {
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); 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 InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation); public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation); public sealed record InitializeFailed(string Reason, int Generation);
@@ -127,15 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.</summary> /// between connects), and dropping it in one state would lose the signal silently.</summary>
private sealed record RediscoveryRaised(RediscoveryEventArgs Args); 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 sealed class RetryConnect
{ {
public static readonly RetryConnect Instance = new(); public static readonly RetryConnect Instance = new();
@@ -196,14 +181,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private DateTime? _rediscoveryNeededUtc; private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason; 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"/>). /// <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> /// 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>(); private IReadOnlyList<string> _desiredRefs = Array.Empty<string>();
@@ -237,8 +214,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).</param> /// 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 /// <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> /// 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 /// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to /// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param> /// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
@@ -250,8 +225,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
IDriverCapabilityInvoker? invoker = null, IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null, TimeSpan? healthPollInterval = null) =>
TimeSpan? driftCheckInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor( Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver, driver,
reconnectInterval ?? DefaultReconnectInterval, reconnectInterval ?? DefaultReconnectInterval,
@@ -259,8 +233,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance, healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty, clusterId ?? string.Empty,
invoker, invoker,
healthPollInterval, healthPollInterval));
driftCheckInterval));
/// <summary> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -291,8 +264,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param> /// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls; /// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param> /// 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 /// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param> /// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor( public DriverInstanceActor(
@@ -302,15 +273,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
IDriverCapabilityInvoker? invoker = null, IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null, TimeSpan? healthPollInterval = null)
TimeSpan? driftCheckInterval = null)
{ {
_driver = driver; _driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId; _driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty; _clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval; _reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval; _healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -381,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); AttachAlarmSource();
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
StartDriftCheck();
}); });
Receive<InitializeFailed>(msg => Receive<InitializeFailed>(msg =>
{ {
@@ -427,7 +395,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driverInstanceId, msg.Reason); _driverInstanceId, msg.Reason);
DetachSubscription(); DetachSubscription();
RecordFault(); RecordFault();
StopDriftCheck();
Become(Reconnecting); Become(Reconnecting);
PublishHealthSnapshot(); PublishHealthSnapshot();
}); });
@@ -435,7 +402,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{ {
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription(); DetachSubscription();
StopDriftCheck();
Become(Reconnecting); Become(Reconnecting);
PublishHealthSnapshot(); PublishHealthSnapshot();
}); });
@@ -458,7 +424,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
}); });
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync); ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
Receive<DataChangeForward>(OnDataChangeForward); Receive<DataChangeForward>(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent; // Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished. // project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -553,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); AttachAlarmSource();
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
StartDriftCheck();
}); });
// A failure here is a no-op regardless of generation — the retry timer keeps trying the // 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. // current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -798,10 +762,6 @@ 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 /// <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 /// 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> /// push data-change / alarm events to an actor that has lost its driver connection.</summary>
@@ -849,119 +809,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoveryHandler = null; _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 /// <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. /// 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 /// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -1137,7 +984,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <inheritdoc /> /// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
StopDriftCheck();
DetachSubscription(); DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the // 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. // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
@@ -1,92 +0,0 @@
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);
}
}
@@ -1,268 +0,0 @@
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;
}
}
@@ -1,113 +0,0 @@
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);
}
}