Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77c8a3569 | |||
| 5184a2e107 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+268
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user