merge: Sql browser layering (#526), IDriver dead-surface removal (#525), scripted-alarm comms-loss quality (#481)
v2-ci / build (push) Successful in 4m16s
v2-ci / unit-tests (push) Failing after 15m30s

Three tracker issues, each falsified by reverting the production change:

- #526 measured before/after that Core.dll + Driver.Sql.dll leave the AdminUI's
  output, and added a csproj-graph guard covering every *.Browser project
  rather than only the one that had an issue filed.
- #525 deleted both unconsumed IDriver members plus the support code that only
  fed them, across 12 drivers and every test stub.
- #481 bridges driver comms loss into scripted-alarm input quality and closes
  #478 Finding 3. Its live gate found a pre-existing defect that makes the
  headline scenario unreachable — filed as #527.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 10:02:59 -04:00
64 changed files with 749 additions and 576 deletions
+1
View File
@@ -32,6 +32,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
+48 -10
View File
@@ -158,18 +158,56 @@ condition's state?") — mirroring the native OT semantic:
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
actually-`Bad` published value flips the bucket and annotates.
**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
as the Layer-4 follow-up (#481).
**Comms loss (Layer 4, #481).** #478 covered input tags whose driver **publishes a data change carrying a
Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's per-item Bad). It did not
cover a driver **comms loss**: a poll driver (Modbus/S7) whose device goes unreachable emits only
`ConnectivityChanged` and goes *silent* on the value feed (see `DriverInstanceActor.Reconnecting`), so the
engine kept the last-known Good value, `WorstInputStatus` stayed Good — and worse, `AreInputsReady` stayed
true, so the predicate went on evaluating a stale value after the source had died.
`DriverHostActor.FanCommsLossToDependencyMux` closes it, symmetrically with the native
`OnDriverConnectivityChanged` path above: on `ConnectivityChanged(false)` it pushes a synthetic
`BadNoCommunication` with a **null value** into the dependency mux for every value ref the driver owns,
resolved from the existing `_nodeIdByDriverRef` map. Alarm-bearing tags are deliberately excluded — a
native condition is not a mux value, and its comms quality goes through the Part 9 annotation instead.
Two things worth knowing before changing this:
- **Nothing is pushed on reconnect, deliberately** — publishing Good would assert a value quality the host
has not observed. Recovery rides the driver's own republish: the `Connected → Reconnecting` edge calls
`DetachSubscription`, so the reconnect's `ResubscribeDesired` builds a *fresh* subscription whose
`PollGroupEngine` initial push runs with `forceRaise: true` and re-publishes every tag with its real
quality. That detach is load-bearing — without it the poll engine would compare against its retained
`LastValues`, see an unchanged value, raise nothing, and strand those refs Bad forever.
- **Virtual tags computed from a comms-lost input now go Bad too.** That follows from using the shared mux
and is the correct reading of "this input's source is unreachable", but it is a behaviour change beyond
scripted alarms.
⚠️ **The signal this hangs off does not currently fire for a mid-run device loss — see #527.** The #481 live
gate on docker-dev found that `DriverInstanceActor.DisconnectObserved` has **no production sender**: the only
paths into `Reconnecting` are a failed *initial* connect and an operator Force Reconnect. A Modbus driver
whose fixture was stopped stayed `Connected` for >10 minutes publishing per-tag Bad, and did not recover when
the fixture came back until the node was restarted. So the fan-out above — and the native `#477` Layer 2
annotation it mirrors — are correctly wired to a signal that a dying device never raises. What partially
covers the gap today is that Modbus publishes Bad *per tag* rather than going silent, so #478 Layer 3 does
carry Bad into scripted input quality for that driver; that is a per-driver accident, not a guarantee, and it
does nothing for native conditions or for recovery. Closing #527 is what makes this path reachable.
**Cold start vs. a driver reporting Bad (#478 Finding 3, closed by #481).** `WorstInputStatus` used to skip
every input whose *value* was null as "no data yet". A comms-lost driver produces exactly that shape — Bad
with no value — so the skip was hiding the case that matters most. The upstream source now stamps a
never-pushed path `BadWaitingForInitialData` and nothing else does, so the engine skips *that specific
code* rather than every null. Deleting the null-skip outright would have been the obvious fix and is wrong
in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, which is
what the skip existed to prevent. Both failure modes are pinned — see the engine-test pair below, each of
which goes red under the opposite mistake.
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing; plus the #481
pair — a pushed Bad carrying no value counts, an input never pushed does not),
`DriverHostActorLiveValueTests` (comms loss pushes Bad for every ref the driver owns, reconnect pushes
nothing, another driver's loss pushes nothing),
`DependencyMuxTagUpstreamSourceTests.The_engine_agrees_with_this_source_on_the_never_pushed_status_code`,
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
+23 -7
View File
@@ -136,15 +136,31 @@ the real per-capability timeout / retry / breaker policies, resolved via
Every driver runs Tier A because no factory passes a tier, so today that means
one uniform policy set.
⚠️ Two `IDriver` members are now **consumerless**: `GetMemoryFootprint()` and
`FlushOptionalCachesAsync()` existed to feed `MemoryTracking`. All 12 drivers
still implement them and nothing calls them. They were left in place rather
than swept, since removing them touches every driver and every test stub —
tracked as Gitea **#525**.
`IDriver` **no longer carries `GetMemoryFootprint()` or
`FlushOptionalCachesAsync()`** (Gitea **#525**). They existed to feed
`MemoryTracking`, deleted with the rest of the driver-tier recycle machinery in
**#522**; after that nothing called them, and their doc-comments still claimed
Core polled the footprint every 30 s, which had not been true since v3. The
sweep removed them from the interface, all 12 drivers, and every test stub,
along with the support code that existed only to feed them — MTConnect's
per-entry byte weights and its cached data-item count, OpcUaClient's
`_discoveredNodeCount`, and MQTT's per-tag byte estimate.
Two consequences worth knowing:
- `AbCipTemplateCache` now has **no production reset path**. Its only caller was
AB CIP's flush — which itself had no caller, so nothing has actually changed
in behaviour, only in how visible the fact is. A UDT redefined on a live
controller keeps its stale shape until the driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
Agent restart. The tests that used to reach it through the flush now drive
that path instead, so they cover a case that actually occurs.
If per-driver memory protection is wanted again, build it for the architecture
as it is now: a process-wide watchdog, not a per-driver tier whose only remedy
requires a supervisor that cannot exist in-process.
as it is now: a process-wide watchdog that can ask drivers to flush, not a
per-driver tier whose only remedy requires a supervisor that cannot exist
in-process. The deleted per-driver implementations are in git history and were
mostly reusable — what was always missing is the trigger.
---
+1 -1
View File
@@ -168,6 +168,6 @@ UDT-typed tags found in the walk.
## Operational Notes
- **Native heap is invisible to the GC.** `GetMemoryFootprint()` reports CLR allocations only; libplctag's native `Tag` heap does not show up there. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+1 -1
View File
@@ -132,6 +132,6 @@ and the UI authoring flow.
## Operational Notes
- **Native heap is invisible to the GC.** As with AB CIP, `GetMemoryFootprint()` reports CLR allocations only; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles.
- **Native heap is invisible to the GC.** As with AB CIP, libplctag's native heap does not show up in CLR allocation figures; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles. (`IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)).
- **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands.
@@ -62,7 +62,8 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Sql.Dialect`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). The browser opens its own transient connection. The **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/` | `ISqlDialect` + `SqlServerDialect`, assembly-split out of `Driver.Sql` (namespace unchanged). **Added by Gitea #526**, which corrected the row above: it used to say the browser referenced the runtime **`Driver.Sql`**, called that "a deliberate deviation from `OpcUaClient.Browser`", and asked that nobody "fix" it without reading this context. The *sharing* premise was right and survives; the conclusion that sharing required the runtime reference was not. The AdminUI references the browser, so that edge put the whole SQL driver runtime — and `ZB.MOM.WW.OtOpcUa.Core` through it — into the AdminUI's build/deploy graph, invisibly (measured before and after: both DLLs present, then absent). The dialect depends on nothing in `Driver.Sql`, so it moved to this leaf, referenced by the runtime driver and the browser alike. It cannot live in `.Contracts`: `ISqlDialect` exposes `DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`, and `.Contracts` is deliberately provider-free — the same shape as `Driver.Mqtt.Transport`. Guarded by `DriverBrowserLayeringTests`, which scans every `*.Browser` csproj in the tree. |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). |
+2
View File
@@ -120,6 +120,8 @@ For Tier A/B (in-process) drivers, the "crash" being counted is a driver-instanc
In-process drivers cannot be recycled by killing the server process — that would take down every other driver, every session, and the OPC UA endpoint. RSS watchdogs and scheduled recycle therefore do **not** apply to Tier A/B. Instead, each driver instance is monitored at a finer grain:
> ⚠️ **The two bullets below were never built as described, and the surface they name is now gone.** `GetMemoryFootprint()` / `FlushOptionalCachesAsync()` did exist on `IDriver`, but nothing ever polled the footprint on a 30 s cadence in v3, no driver ever declared a cache budget in `DriverConfig`, and the recycle machinery their only consumer belonged to was deleted in Gitea #522. Both members were removed from `IDriver` in Gitea #525. Retained here as the design intent a future process-wide watchdog could revive — not as a description of the system.
- **Per-instance allocation tracking**: drivers expose a `GetMemoryFootprint()` capability returning bytes attributable to their own caches (symbol cache, subscription items, queued operations). The Core polls this every 30 s and logs growth slope per driver instance.
- **Soft-limit on cached state**: each driver declares a memory budget for its caches in `DriverConfig`. On breach, the Core asks the driver to flush optional caches (e.g. discard symbol cache, force re-discovery). No process action.
- **Escalation rule**: if a driver instance's footprint cannot be bounded by cache flushing — or if growth is in opaque allocations the driver can't account for — that driver is a candidate for **promotion to Tier C**. Process recycle is the only safe leak remediation, and the only way to apply process recycle to a single driver is to give it its own process.
@@ -64,26 +64,5 @@ public interface IDriver
/// has a running / not-running distinction worth reporting.</para></summary>
/// <returns>The driver's current health snapshot.</returns>
DriverHealth GetHealth();
/// <summary>
/// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables).
/// <para>⚠️ <b>Nothing calls this (Gitea #525).</b> Its only consumer was <c>MemoryTracking</c>,
/// deleted with the rest of the driver-tier recycle machinery in Gitea #522 — the doc-comment here
/// used to claim Core polled it every 30 s, which was never true in v3. All 12 drivers still
/// implement it; most return a real number, and returning <c>0</c> is equally correct today. Do not
/// read a value from it and act on it without first building a consumer that is actually wired.</para>
/// </summary>
/// <returns>The approximate memory footprint in bytes.</returns>
long GetMemoryFootprint();
/// <summary>
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
/// Required-for-correctness state must NOT be flushed.
/// <para>⚠️ <b>Nothing calls this (Gitea #525)</b> — same reason as
/// <see cref="GetMemoryFootprint"/>: the memory-pressure path that would have invoked it is
/// gone.</para>
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}
@@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// </remarks>
public sealed class ScriptedAlarmEngine : IDisposable
{
/// <summary>
/// OPC UA Part 4 <c>BadWaitingForInitialData</c> — the status the upstream source stamps on a
/// path nothing has ever pushed to, and the marker <see cref="WorstInputStatus"/> uses to tell
/// genuine cold start apart from a driver reporting Bad.
/// <para>Inlined rather than taken from <c>Commons.OpcUa.OpcUaStatus</c> because this project
/// deliberately does not reference <c>Commons</c> — the engine is pure evaluation logic over
/// <c>Core.Abstractions</c>. The value is fixed by the OPC UA spec, so the duplication cannot
/// drift; <c>DependencyMuxTagUpstreamSourceTests</c> pins the two agreeing.</para>
/// </summary>
private const uint StatusNeverPushed = 0x80320000u;
private readonly ITagUpstreamSource _upstream;
private readonly IAlarmStateStore _store;
private readonly ScriptLoggerFactory _loggerFactory;
@@ -736,19 +747,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
WorstInputStatusCode: worstInputStatus);
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
/// cache ⇒ Good (0).</summary>
/// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
/// <para><b>"Never pushed" is a specific status code, not "the value is null" (#481, closing #478
/// Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
/// observation.</para></summary>
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
uint worst = 0u;
var worstSeverity = 0u;
foreach (var kv in cache)
{
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
// No entry at all — nothing was observed, so there is nothing to report on.
if (kv.Value is null) continue;
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
if (kv.Value.StatusCode == StatusNeverPushed) continue;
var status = kv.Value.StatusCode;
var severity = status >> 30;
if (severity > worstSeverity)
@@ -1017,15 +1017,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
_templateCache.Clear();
return Task.CompletedTask;
}
// ---- ITagDiscovery ----
@@ -4,9 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Cache of UDT shape descriptors keyed by <c>(deviceHostAddress, templateInstanceId)</c>.
/// Populated on demand during discovery + whole-UDT reads; flushed via
/// <see cref="AbCipDriver.FlushOptionalCachesAsync"/> and on device
/// <c>ReinitializeAsync</c>.
/// Populated on demand during discovery + whole-UDT reads.
/// <para><b>Nothing in production clears it.</b> Its only caller was
/// <c>AbCipDriver.FlushOptionalCachesAsync</c>, deleted in Gitea #525 as an unconsumed
/// <c>IDriver</c> member — but that member never had a caller either, so this is not a change in
/// behaviour, only in how visible the fact is. The comment here used to claim a device
/// <c>ReinitializeAsync</c> also flushed it; no such call ever existed. A UDT redefined on a live
/// controller therefore keeps its stale shape until the driver instance is rebuilt.</para>
/// </summary>
/// <remarks>
/// Templates are decoded by <see cref="CipTemplateObjectDecoder"/> (CIP Template Object
@@ -34,7 +38,8 @@ public sealed class AbCipTemplateCache
public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) =>
_shapes[(deviceHostAddress, templateInstanceId)] = shape;
/// <summary>Drop every cached shape — called on <see cref="AbCipDriver.FlushOptionalCachesAsync"/>.</summary>
/// <summary>Drop every cached shape. Retained as ordinary cache API + the tests' reset seam;
/// see the type remarks for why no production path calls it.</summary>
public void Clear() => _shapes.Clear();
/// <summary>Count of cached shapes — exposed for diagnostics + tests.</summary>
@@ -229,11 +229,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Gets the device count.
@@ -279,16 +279,6 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null)
: new DriverHealth(DriverState.Unknown, _lastComputeUtc, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
// The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
_evaluator.ClearCompiledScripts();
return Task.CompletedTask;
}
// ---- IDependencyConsumer ----
@@ -311,10 +311,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <inheritdoc />
public DriverHealth GetHealth() => Volatile.Read(ref _health);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the number of configured devices.</summary>
internal int DeviceCount => _devices.Count;
@@ -680,22 +680,6 @@ public sealed class GalaxyDriver
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => _hostStatuses.Snapshot();
/// <inheritdoc />
public long GetMemoryFootprint()
{
// Estimated footprint: 64 bytes × tracked item handles (one gw subscription entry
// per bound tag) + 256 bytes × tracked driver subscriptions (registry overhead per
// OPC UA monitored item). Returns 0 when no subscriptions are active. These
// constants are conservative — a 50k-tag set occupies ~3 MB and registers clearly
// with the server's cache-flush heuristic.
const long BytesPerItemHandle = 64L; // TagBinding + reverse-map entry
const long BytesPerSubscription = 256L; // SubscriptionEntry overhead
return (_subscriptions.TrackedItemHandleCount * BytesPerItemHandle)
+ (_subscriptions.TrackedSubscriptionCount * BytesPerSubscription);
}
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ===== ITagDiscovery (PR 4.1) =====
@@ -62,21 +62,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
/// for an <i>approximate</i> driver-attributable footprint that Core polls every 30 s to
/// decide whether to ask for a flush, so an order-of-magnitude estimate from the two things
/// that actually scale with a plant (declared data items, live observations) is the right
/// precision. Measuring real retained size would need a heap walk per poll.
/// </summary>
private const int ProbeModelBytesPerDataItem = 512;
/// <summary>Estimated retained bytes per live <c>dataItemId → snapshot</c> entry.</summary>
private const int IndexBytesPerEntry = 256;
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
private const int TagBytesPerEntry = 256;
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
@@ -323,15 +308,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </summary>
/// <remarks>
/// <para>
/// <b>Why one field instead of three.</b> These three values are read <i>outside</i>
/// <see cref="_lifecycle"/> — by <see cref="ReadAsync"/>, by
/// <see cref="GetOrFetchProbeModelAsync"/> (Task 12's browse), and by
/// <see cref="GetMemoryFootprint"/> — while every write happens under it. As three
/// separate fields they could be observed mid-update: a reader could see a dropped
/// <c>ProbeModel</c> beside a stale non-zero data-item count and report a footprint for
/// a cache that no longer exists, or pair one initialize's client with the previous
/// one's model. Packing them makes every observation internally consistent by
/// construction, with no lock on the read path.
/// <b>Why one field instead of several.</b> These values are read <i>outside</i>
/// <see cref="_lifecycle"/> — by <see cref="ReadAsync"/> and by
/// <see cref="GetOrFetchProbeModelAsync"/> (Task 12's browse) — while every write
/// happens under it. As separate fields they could be observed mid-update, pairing one
/// initialize's client with the previous one's model. Packing them makes every
/// observation internally consistent by construction, with no lock on the read path.
/// (A third value, a cached data-item count, lived here until Gitea #525 deleted its
/// only reader — <c>GetMemoryFootprint</c> — along with the rest of that dead surface.)
/// </para>
/// <para>
/// <b>Why not simply take the lock in those readers.</b> Both readers <c>await</c> the
@@ -423,8 +407,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <summary>
/// The cached <c>/probe</c> device model, or <c>null</c> when it has not been fetched or was
/// dropped by <see cref="FlushOptionalCachesAsync"/>. Prefer
/// <see cref="GetOrFetchProbeModelAsync"/>, which cannot observe the flushed hole.
/// dropped by <see cref="InvalidateProbeModel"/> on an Agent restart. Prefer
/// <see cref="GetOrFetchProbeModelAsync"/>, which cannot observe the dropped hole.
/// </summary>
internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel;
@@ -596,58 +580,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <inheritdoc/>
public DriverHealth GetHealth() => ReadHealth();
/// <inheritdoc/>
/// <remarks>
/// Sums the three caches that scale with plant size: the <c>/probe</c> device model (by
/// declared data item), the live observation index (by indexed data item), and the authored
/// tag table. See <see cref="ProbeModelBytesPerDataItem"/> for why the weights are coarse
/// constants.
/// </remarks>
public long GetMemoryFootprint() =>
((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
+ ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry);
/// <inheritdoc/>
/// <remarks>
/// Drops the <c>/probe</c> device model — the browse cache, which is re-derivable from the
/// Agent at any time — and keeps the observation index, which is required for correctness
/// (it is the read surface, and re-deriving it would mean a full re-prime the caller did not
/// ask for). Flushing cannot wedge the driver: every consumer of the model goes through
/// <see cref="GetOrFetchProbeModelAsync"/>, which re-fetches and re-caches on a miss.
/// <para>
/// The drop is a single <see cref="Interlocked.CompareExchange{T}"/> of a whole new
/// <see cref="AgentSession"/>, so the model and its data-item count can never be seen
/// out of step (which would skew <see cref="GetMemoryFootprint"/>), and a flush that
/// races a concurrent teardown or re-initialize is discarded rather than resurrecting
/// the session it was reading.
/// </para>
/// </remarks>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
var session = Volatile.Read(ref _session);
if (session?.ProbeModel is null)
{
return Task.CompletedTask;
}
var flushed = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
if (!ReferenceEquals(Interlocked.CompareExchange(ref _session, flushed, session), session))
{
// Someone swapped the session while we were building the flushed copy — their state is
// newer than ours, and publishing would undo it. Whatever they installed is either a
// teardown (no cache to flush) or a fresh initialize (a cache the operator's budget
// breach never saw), so dropping this flush is correct, not merely safe.
return Task.CompletedTask;
}
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.",
_driverInstanceId, session.ProbeModelDataItemCount);
return Task.CompletedTask;
}
/// <inheritdoc/>
/// <remarks>
/// <para>
@@ -963,9 +895,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </para>
/// <para>
/// <b>The model comes from <see cref="GetOrFetchProbeModelAsync"/>, never from
/// <see cref="CachedProbeModel"/>.</b> <see cref="FlushOptionalCachesAsync"/> may have
/// dropped the cache under memory pressure; reading the field directly would make a
/// flushed driver browse as an Agent with no data items, permanently.
/// <see cref="CachedProbeModel"/>.</b> <see cref="InvalidateProbeModel"/> may have
/// dropped the cache after an Agent restart; reading the field directly would make such
/// a driver browse as an Agent with no data items, permanently.
/// </para>
/// <para>
/// <b>This throws, and that is the opposite of <see cref="ReadAsync"/>'s posture on
@@ -1250,8 +1182,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <para>
/// Publishing the answer into <see cref="AgentSession.ProbeModel"/> is the opposite
/// mistake: it would put a second writer on a field the lifecycle owns under a
/// compare-and-swap, and would silently re-warm a cache that
/// <see cref="FlushOptionalCachesAsync"/> — or an Agent restart, see
/// compare-and-swap, and would silently re-warm a cache that an Agent restart — see
/// <see cref="AnnounceAgentRestart"/> — deliberately dropped. The parsed model is
/// discarded; only the fact that a request completed is used.
/// </para>
@@ -1473,9 +1404,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <see cref="DiscoverAsync"/>, which reads
/// <see cref="GetOrFetchProbeModelAsync"/>; with the cache still warm that call answers
/// from a model describing a process that no longer exists, and the "rediscovery" would
/// diff the stale tree against itself and change nothing. Dropped through the same
/// compare-and-swap <see cref="FlushOptionalCachesAsync"/> uses, so a lifecycle change
/// landing mid-drop wins rather than being undone.
/// diff the stale tree against itself and change nothing. Dropped through a
/// compare-and-swap, so a lifecycle change landing mid-drop wins rather than being
/// undone.
/// </para>
/// <para>
/// <b>Once per change, not once per chunk.</b> Every chunk after a restart carries the new
@@ -1514,12 +1445,16 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
/// <summary>
/// Drops the cached <c>/probe</c> device model so the next consumer re-fetches it. Same
/// compare-and-swap discipline as <see cref="FlushOptionalCachesAsync"/> — retried, because
/// unlike a flush this drop is a correctness requirement rather than a courtesy: losing the
/// race must not leave a model from a dead Agent installed.
/// Drops the cached <c>/probe</c> device model so the next consumer re-fetches it.
/// <c>internal</c> rather than <c>private</c> so the discovery tests can exercise the real
/// drop path; production calls it from <see cref="AnnounceAgentRestart"/>.
/// Compare-and-swap, and <b>retried</b> — this drop is a correctness requirement rather
/// than a courtesy: losing the race must not leave a model from a dead Agent installed.
/// (The courtesy version, <c>FlushOptionalCachesAsync</c>, took the same lock-free
/// approach but gave up on a lost race; it went with the rest of the unconsumed
/// memory-pressure surface in Gitea #525.)
/// </summary>
private void InvalidateProbeModel()
internal void InvalidateProbeModel()
{
while (true)
{
@@ -1529,7 +1464,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
return;
}
var dropped = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
var dropped = session with { ProbeModel = null };
if (ReferenceEquals(Interlocked.CompareExchange(ref _session, dropped, session), session))
{
return;
@@ -2191,7 +2126,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// sequence that had just been rejected.
/// </para>
/// <para>
/// Compare-and-swap for the same reason <see cref="FlushOptionalCachesAsync"/> uses one:
/// Compare-and-swap for the same reason <see cref="InvalidateProbeModel"/> uses one:
/// a lifecycle change landing mid-update has newer state, so this write is dropped
/// rather than replayed over it.
/// </para>
@@ -2221,7 +2156,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// The <c>/probe</c> device model: the cached copy when warm, otherwise a fresh
/// deadline-bounded <c>/probe</c> that re-populates the cache. This is the accessor every
/// model consumer (Task 12's <c>DiscoverAsync</c>) must use, so that
/// <see cref="FlushOptionalCachesAsync"/> can never leave browse permanently disabled.
/// <see cref="InvalidateProbeModel"/> can never leave browse permanently disabled.
/// </summary>
/// <remarks>
/// Lock-free by design — see the <see cref="_session"/> remarks for why this does not take
@@ -2261,7 +2196,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
// Publish only onto the session we actually fetched against.
var recached = session with { ProbeModel = model, ProbeModelDataItemCount = CountDataItems(model) };
var recached = session with { ProbeModel = model };
_ = Interlocked.CompareExchange(ref _session, recached, session);
return model;
@@ -2416,7 +2351,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
// cache, and the pump's opening cursor together or none of them.
Volatile.Write(
ref _session,
new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence));
new AgentSession(client, probe, current.InstanceId, current.NextSequence));
var index = new MTConnectObservationIndex(binding.IndexTags);
index.Apply(current);
@@ -2738,13 +2673,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|| current.SampleIntervalMs != incoming.SampleIntervalMs
|| current.SampleCount != incoming.SampleCount;
/// <summary>
/// Counts every data item the device model declares. Done once, when the model is cached,
/// rather than re-walking the tree on every 30 s <see cref="GetMemoryFootprint"/> poll.
/// </summary>
private static int CountDataItems(MTConnectProbeModel model) =>
model.Devices.Sum(d => d.AllDataItems().Count());
/// <summary>
/// Runs one unary agent call under a deadline linked to the caller's token. Belt-and-braces
/// over the production client's own per-call deadline: the seam does not oblige an
@@ -3196,17 +3124,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <summary>
/// The live agent client and the <c>/probe</c> cache derived from it, as one immutable
/// unit. Immutable so that publishing a change is a single reference swap — see the
/// <see cref="_session"/> remarks for why the three values must move together.
/// <see cref="_session"/> remarks for why these values must move together.
/// </summary>
/// <param name="Client">The agent client this session owns and will dispose on teardown.</param>
/// <param name="ProbeModel">
/// The cached device model, or <c>null</c> once <see cref="FlushOptionalCachesAsync"/> has
/// The cached device model, or <c>null</c> once <see cref="InvalidateProbeModel"/> has
/// dropped it. Never null at the moment a session is first published.
/// </param>
/// <param name="ProbeModelDataItemCount">
/// Data items declared by <paramref name="ProbeModel"/>, counted once at cache time and
/// always <c>0</c> when it is <c>null</c>.
/// </param>
/// <param name="InstanceId">
/// The Agent's <c>instanceId</c> as last observed. Updated in place (by
/// <see cref="PublishAgentCursor"/>) when the pump sees the Agent restart, so the
@@ -3220,7 +3144,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
private sealed record AgentSession(
IMTConnectAgentClient Client,
MTConnectProbeModel? ProbeModel,
int ProbeModelDataItemCount,
long InstanceId,
long NextSequence);
@@ -282,10 +282,6 @@ public sealed class ModbusDriver
/// Barrier-protected publish of a new <c>_health</c> snapshot.
/// </summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
@@ -88,15 +88,6 @@ public sealed class MqttDriver
/// <summary>The publish failed, or the driver has no live broker connection to publish through.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// Rough per-tag driver-attributable cost: the parsed <see cref="MqttTagDefinition"/> (five
/// strings — RawPath, topic, JSONPath and the record header) plus its cached
/// <see cref="DataValueSnapshot"/> and the two dictionary entries that index it. An estimate
/// by construction — <c>GetMemoryFootprint</c> exists to drive a cache-budget decision, not
/// to be exact.
/// </summary>
private const long ApproxBytesPerAuthoredTag = 512;
/// <summary>
/// The single folder <see cref="DiscoverAsync"/> streams every variable under — and therefore
/// the only subtree path a rediscovery <c>ScopeHint</c> can name truthfully. See
@@ -429,24 +420,6 @@ public sealed class MqttDriver
};
}
/// <summary>
/// Approximate driver-attributable footprint: the authored definition table and the
/// last-value cache that indexes it. Both are sized by the authored tag count, which is the
/// only thing a deployment can grow.
/// </summary>
/// <returns>The approximate memory footprint in bytes.</returns>
public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag;
/// <summary>
/// No-op, deliberately. This driver holds no optional cache: the authored table is the
/// address space itself, and the last-value cache <b>is</b> <see cref="IReadable"/> — MQTT is
/// subscribe-first, so a dropped value is not re-fetchable, and flushing it would turn every
/// node Bad until its topic next published, which for a slow-changing tag can be never.
/// Birth/alias state (Sparkplug, P2) is correctness state for the same reason.
/// </summary>
/// <param name="cancellationToken">Unused; present for the <see cref="IDriver"/> shape.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
@@ -129,12 +129,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>The <see cref="CertificateValidator"/> that owns <see cref="_certValidationHandler"/>.</summary>
private CertificateValidator? _certValidatorRef;
/// <summary>
/// Approximate count of discovered nodes (folders + variables). Updated by
/// <see cref="DiscoverAsync"/> and used to report a non-zero
/// <see cref="GetMemoryFootprint"/> to the Core allocation-slope detector.
/// </summary>
private volatile int _discoveredNodeCount;
/// <summary>
/// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery
/// when the session's keep-alive channel reports a bad status. Null outside the
/// reconnecting window; constructed lazily inside the keep-alive handler. Guarded by
@@ -644,23 +638,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
// Each discovered node (folder or variable) contributes ~512 bytes to cover the
// DriverAttributeInfo record, the browse-name string, and the stable nsu= reference string
// stored in the address-space builder. The real number depends on string length + box sizes;
// the constant is conservative enough that a 10k-node remote server reports ~5 MB — well
// within the budget and detectable by the Core slope alarm.
public long GetMemoryFootprint() => _discoveredNodeCount * 512L;
/// <inheritdoc />
// The OPC UA Client driver holds no independently-flushable cache beyond what the
// address-space builder retains — a flush here resets the discovered-node footprint
// counter and signals Core that re-discovery will rebuild it cleanly from the remote server.
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
_discoveredNodeCount = 0;
return Task.CompletedTask;
}
// ---- IReadable ----
@@ -926,9 +903,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken)
.ConfigureAwait(false);
// Update the footprint counter so GetMemoryFootprint() returns a real estimate
// after each discovery pass.
_discoveredNodeCount = discovered;
}
finally { _gate.Release(); }
}
@@ -328,9 +328,6 @@ public sealed class S7Driver
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass <c>"{}"</c> or
@@ -481,11 +478,6 @@ public sealed class S7Driver
/// </summary>
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();
/// <inheritdoc />
// The Plc instance + one 240-960 byte PDU buffer is under 4 KB; returns 0 because the
// IDriver contract asks for a driver-attributable growth number and S7.Net doesn't expose one.
public long GetMemoryFootprint() => 0;
// ---- IReadable ----
/// <inheritdoc />
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// Sourced from the shared <see cref="DriverTypeNames"/> set, which is what the driver's own
/// <c>SqlDriver.DriverTypeName</c> resolves to. This used to read that interim local constant, and
/// the comment here said so; repointing it was deferred until a factory was registered against
/// <see cref="DriverTypeNames.Sql"/> (else <c>DriverTypeNamesGuardTests</c> goes red). One exists
/// now, and reading the shared constant is also what lets this browser stay off the runtime driver
/// — <c>SqlDriver</c> is the only type it still needed from there (Gitea #526).
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
public string DriverType => DriverTypeNames.Sql;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
@@ -11,15 +11,34 @@
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<!--
The layering exception this project used to carry is GONE (Gitea #526). It referenced the
runtime Driver.Sql project, and the AdminUI references this browser: the AdminUI's
build/deploy graph therefore pulled in the whole SQL driver runtime and, through it,
ZB.MOM.WW.OtOpcUa.Core.
The premise of the old comment was sound and survives — the dialect's catalog SQL
(ISqlDialect / SqlServerDialect) *is* the browse engine, so it must be SHARED here rather
than duplicated. What was wrong was the conclusion that sharing it required a reference to
the runtime driver. Those two types depend on nothing from Driver.Sql, so they now live in
the .Dialect leaf below, referenced by the runtime driver and by this browser alike.
Note this is the .Transport shape, not the cheap one: ISqlDialect exposes DbProviderFactory
and SqlServerDialect returns SqlClientFactory.Instance, so the dialect cannot move into
.Contracts (deliberately provider-free) — it needs a leaf of its own.
MEASURED, not assumed: after this change ZB.MOM.WW.OtOpcUa.Driver.Sql.dll and
ZB.MOM.WW.OtOpcUa.Core.dll are both absent from the AdminUI's output, and both were present
immediately before it. #512 removed the matching MQTT reference and expected Core to
disappear with it; it did not, because this project was the remaining cause.
The transitive Microsoft.Data.SqlClient on AdminUI is unchanged and remains accepted —
AdminUI already carries SqlClient for ConfigDb. Design doc context, still worth reading
before changing the sharing decision: docs/plans/2026-07-15-sql-poll-driver-design.md §2.
Guarded by DriverBrowserLayeringTests, which scans every *.Browser csproj in the tree.
-->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// implemented: <c>SqlCatalogGate</c> resolves every authored table/column against the live
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
@@ -96,7 +96,7 @@ public interface ISqlDialect
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <para>Needed by <c>SqlCatalogGate</c>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
@@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- Assembly is .Dialect; the NAMESPACE stays ZB.MOM.WW.OtOpcUa.Driver.Sql, matching the
Driver.Mqtt.Transport precedent. The split is a build-graph concern, not an API one, so
extracting these two types cost zero `using` changes anywhere in the tree. -->
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -397,14 +397,6 @@ public sealed class SqlDriver
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
/// <returns>Always zero.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
@@ -13,6 +13,9 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<!-- ISqlDialect / SqlServerDialect moved to the .Dialect leaf so Driver.Sql.Browser can share
the catalog SQL without referencing this runtime project (Gitea #526). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
@@ -208,18 +208,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
// This driver holds no flushable symbol cache — BrowseSymbolsAsync streams and discards; the
// footprint reflects live allocations only: ~256 bytes per pre-declared tag (tag-definition
// record + dictionary overhead) and ~512 bytes per active native subscription.
public long GetMemoryFootprint() =>
(_tagsByRawPath.Count * 256L) + (_nativeSubs.Count * 512L);
/// <inheritdoc />
// No flushable cache exists in this driver — the symbol table is streamed fresh on every
// DiscoverAsync call. This is a no-op but is deliberately present so Core's cache-budget
// enforcement sees a compliant Tier-A driver.
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the count of configured devices.</summary>
internal int DeviceCount => _devices.Count;
@@ -269,7 +269,7 @@ else
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
@@ -22,10 +22,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
// the factory" — went stale when the constant landed; the key is correct either way.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
@@ -24,8 +24,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
@@ -41,8 +41,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
.Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is
reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the
runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own
csproj comment. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
</ItemGroup>
@@ -1009,9 +1009,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <c>alerts</c> row is published here — driver comms health has its own status surface
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
/// </para>
/// <para>
/// <b>#481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency
/// mux.</b> #478 gave a scripted condition the worst quality of its input tags, but only for
/// inputs whose driver actually <i>publishes</i> a Bad data change. A poll driver whose device
/// goes unreachable publishes nothing at all — it emits this signal and goes silent — so the
/// engine kept its last Good value, <c>WorstInputStatus</c> stayed Good, and worse,
/// <c>AreInputsReady</c> stayed true, so the predicate went on evaluating a stale value after
/// the source had died. <see cref="FanCommsLossToDependencyMux"/> closes that by pushing a
/// synthetic Bad for every value ref the driver owns.
/// </para>
/// </summary>
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
// #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
// publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
// return for exactly that reason.
if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId);
if (_opcUaPublishActor is null) return;
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
@@ -1038,6 +1053,68 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
}
/// <summary>
/// #481 (Layer 4) — push a synthetic <see cref="OpcUaStatus.BadNoCommunication"/> into the dependency
/// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
/// the source die instead of holding its last Good value indefinitely.
/// </summary>
/// <remarks>
/// <para>
/// <b>The ref set already existed.</b> #481 asked for a driver → all-tag-refs map to be built;
/// <see cref="_nodeIdByDriverRef"/> is exactly that, keyed <c>(DriverInstanceId, RawPath)</c> and
/// rebuilt from the composition's <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> on every apply.
/// Alarm-bearing tags live in the separate <see cref="_alarmNodeIdByDriverRef"/> and are
/// deliberately absent here — a native condition is not a mux value, and its comms quality is
/// annotated by the caller through the Part 9 path instead.
/// </para>
/// <para>
/// <b>Value <c>null</c>, not the last-known value.</b> The honest statement is "there is no
/// current value", and the engine's <c>AreInputsReady</c> gate holds the predicate on any Bad
/// regardless, so no script observes the null. It does mean the Bad must survive
/// <c>WorstInputStatus</c>, which until #481 skipped every null-valued entry as cold start —
/// hence the paired change stamping never-pushed paths <c>BadWaitingForInitialData</c> so the two
/// are tellable apart. Without that half this fan-out would be silently inert.
/// </para>
/// <para>
/// <b>Nothing is pushed on reconnect, deliberately.</b> Publishing Good would assert a value
/// quality this actor has not observed. Recovery rides the driver's own republish:
/// <c>DriverInstanceActor</c> calls <c>DetachSubscription</c> on the Connected → Reconnecting
/// edge, which nulls the subscription handle, so the reconnect's <c>ResubscribeDesired</c> builds
/// a <i>fresh</i> subscription whose <c>PollGroupEngine</c> initial push runs with
/// <c>forceRaise: true</c> and re-publishes every tag with its real quality. That detach is what
/// makes recovery work: without it the poll engine would compare against its retained
/// <c>LastValues</c>, see an unchanged Good value, raise nothing, and leave these refs Bad
/// forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert.
/// </para>
/// <para>
/// Ungated by redundancy role, for the same reason as the native-condition annotation above: a
/// Secondary must keep its scripted-alarm evaluation coherent for failover. The mux fan-out
/// reaches only refs something is actually subscribed to, so a driver whose tags feed no script
/// and no virtual tag costs one dictionary scan and no delivery.
/// </para>
/// </remarks>
/// <param name="driverInstanceId">The driver child that just reported comms loss.</param>
private void FanCommsLossToDependencyMux(string driverInstanceId)
{
if (_dependencyMux is null) return;
var ts = DateTime.UtcNow;
var pushed = 0;
foreach (var (driverId, rawPath) in _nodeIdByDriverRef.Keys)
{
if (driverId != driverInstanceId) continue;
_dependencyMux.Tell(new DriverInstanceActor.AttributeValuePublished(
driverInstanceId, rawPath, Value: null, OpcUaStatus.BadNoCommunication, ts));
pushed++;
}
if (pushed > 0)
_log.Debug(
"DriverHost {Node}: driver {Driver} disconnected — pushed BadNoCommunication for {Count} mux ref(s)",
_localNode, driverInstanceId, pushed);
}
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
@@ -2238,10 +2315,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private void HandleRestartDriver(RestartDriver msg)
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
@@ -26,11 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// </remarks>
public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
{
// OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The
// codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver,
// the Wonderware historian client) inline this same 0x80000000u, and the engine's own
// AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
private const uint StatusBad = 0x80000000u;
// The status an "unknown path" snapshot carries — a path nothing has ever pushed to.
//
// This is deliberately BadWaitingForInitialData rather than the bare Bad (0x80000000u) it used
// to be, and the distinction is load-bearing (#481, closing #478 Finding 3). Both codes are Bad,
// so the engine's AreInputsReady gate treats them identically and the cold-start hold is
// unchanged. What changed is that "no data has ever arrived" is now tellable apart from "a
// driver pushed Bad", which previously looked identical whenever the pushed Bad carried a null
// value — the shape a comms-lost driver produces. WorstInputStatus keys off exactly this code to
// decide which of the two it is looking at.
//
// The code is also the honest one: Part 4 defines BadWaitingForInitialData as precisely this
// state, and it is what a freshly materialised OPC UA variable already reports before its first
// publish.
private const uint StatusNeverPushed = OpcUaStatus.BadWaitingForInitialData;
// Intentionally never pruned: cold-start semantics depend on retained last values so
// ReadTag can answer synchronously for any path that has ever been pushed.
@@ -90,9 +100,9 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
// No value has been pushed for this path yet — return a Bad-quality placeholder so
// the engine's cold-start guard holds the prior condition rather than NRE-ing on a
// null value cast.
// null value cast. See StatusNeverPushed for why the specific code matters.
var now = _clock();
return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
return new DataValueSnapshot(Value: null, StatusCode: StatusNeverPushed, SourceTimestampUtc: null, ServerTimestampUtc: now);
}
// No-replay contract: a new subscriber does NOT receive a synthetic initial
@@ -72,8 +72,6 @@ internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate.</summary>
@@ -92,8 +90,6 @@ internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string
return Task.CompletedTask;
}
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Configurable IDriverFactory: delegates construction to a supplied func and records what it made.</summary>
@@ -0,0 +1,152 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
/// <summary>
/// Pins the layering rule for <b>every</b> bespoke driver browser in the tree: a <c>*.Browser</c>
/// project must not reach its runtime <c>*.Driver</c> project, by any path.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a per-driver test.</b> Gitea #512 removed this violation from
/// the MQTT browser and pinned it with <c>MqttBrowserLayeringTests</c>. The identical violation was
/// sitting in <c>Driver.Sql.Browser</c> the whole time and no guard saw it, because the guard was
/// written for the driver that happened to have an issue filed (#526). This test enumerates the
/// browsers off disk, so a browser added later is covered on the day it is added.
/// </para>
/// <para>
/// <b>Why it reads csproj files rather than loaded assemblies.</b> The cost being prevented is a
/// build/deploy-graph cost: the AdminUI references these browsers, so a runtime driver reachable
/// from one lands in the AdminUI's output whether or not a single type is used. The compiler emits
/// no assembly reference for an unused <c>ProjectReference</c>, so an assembly-reference test —
/// which is what <c>MqttBrowserLayeringTests</c> does, and says so — cannot see that case. The two
/// are complementary: this one catches the reference, that one catches the usage.
/// </para>
/// <para>
/// <b>The walk is transitive.</b> Asserting only on direct references would pass a browser that
/// reached its runtime driver through a shared leaf, which is the same defect one hop further out
/// — the hazard <c>MqttBrowserLayeringTests</c> guards for <c>.Transport</c> by hand.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DriverBrowserLayeringTests
{
/// <summary>
/// Browsers known to exist when this guard was written. Asserted as a MINIMUM, not an exact set —
/// a new browser must be covered automatically, so this list only defends against the discovery
/// itself silently finding nothing (a wrong repo root, a moved directory), which would make every
/// assertion below vacuously true.
/// </summary>
private static readonly string[] KnownBrowsers =
[
"ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser",
];
[Fact]
public void Every_bespoke_browser_is_discovered()
{
// The positive control for the whole file. Every other test here is an ABSENCE assertion over a
// discovered set, so an empty set passes them all.
var discovered = DiscoverBrowsers().Select(p => Path.GetFileNameWithoutExtension(p)!).ToArray();
foreach (var known in KnownBrowsers)
discovered.ShouldContain(known);
}
[Fact]
public void Every_browser_declares_at_least_one_project_reference()
// The second half of the control: proves the csproj parse actually returns something, so a
// regex that silently stopped matching cannot turn the rule below into a no-op.
=> DiscoverBrowsers().ShouldAllBe(p => DirectReferences(p).Count > 0);
[Fact]
public void No_browser_reaches_its_runtime_driver_project()
{
var offenders = new List<string>();
foreach (var browser in DiscoverBrowsers())
{
var browserName = Path.GetFileNameWithoutExtension(browser)!;
// "ZB…Driver.Sql.Browser" -> "ZB…Driver.Sql": the sibling runtime project.
var runtime = browserName[..^".Browser".Length];
var reached = TransitiveReferences(browser);
if (reached.Contains(runtime))
offenders.Add($"{browserName} -> {runtime}");
}
offenders.ShouldBeEmpty(
"a driver browser reaches its runtime driver project. The AdminUI references these browsers, "
+ "so the whole driver runtime lands in its build/deploy graph — the cost is invisible, which "
+ "is exactly why it needs a guard. If shared code is needed, put transport-free protocol or "
+ "contract types in .Contracts, and anything that must name a provider/vendor type in a leaf "
+ "project of its own (see Driver.Mqtt.Transport and Driver.Sql.Dialect). Do NOT resolve this "
+ "by copying the shared code into the browser: for anything touching credentials, TLS or "
+ "identifier quoting, a second copy is a silent security divergence rather than a build break.");
}
// ---- csproj graph ------------------------------------------------------------------------------
private static readonly Regex ProjectReferencePattern = new(
"<ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static IReadOnlyList<string> DiscoverBrowsers() =>
[.. Directory
.EnumerateDirectories(Path.Combine(RepoRoot.Value, "src", "Drivers"), "*.Browser")
.Select(d => Path.Combine(d, Path.GetFileName(d) + ".csproj"))
.Where(File.Exists)
.OrderBy(p => p, StringComparer.Ordinal)];
/// <summary>Absolute paths of the csproj files one project references directly.</summary>
private static IReadOnlyList<string> DirectReferences(string csprojPath)
{
var dir = Path.GetDirectoryName(csprojPath)!;
return
[
.. ProjectReferencePattern
.Matches(File.ReadAllText(csprojPath))
.Select(m => Path.GetFullPath(
Path.Combine(dir, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar))))
];
}
/// <summary>Assembly-ish names of every project reachable from <paramref name="csprojPath"/>.</summary>
private static HashSet<string> TransitiveReferences(string csprojPath)
{
var reached = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Stack<string>(DirectReferences(csprojPath));
while (pending.Count > 0)
{
var next = pending.Pop();
if (!reached.Add(Path.GetFileNameWithoutExtension(next)!) || !File.Exists(next)) continue;
foreach (var onward in DirectReferences(next)) pending.Push(onward);
}
return reached;
}
/// <summary>
/// Walks up from the test binary until the solution file appears. The tests run out of
/// <c>bin/Debug/net10.0</c>, so no fixed relative depth is reliable across host layouts.
/// </summary>
private static readonly Lazy<string> RepoRoot = new(() =>
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
return dir.FullName;
});
}
@@ -38,11 +38,23 @@ public sealed class FakeUpstream : ITagUpstreamSource
}
}
/// <summary>Reads the current value of a tag, or returns a bad-status snapshot if not set.</summary>
/// <summary>
/// OPC UA <c>BadWaitingForInitialData</c> — the status a never-pushed path reports, matching what
/// the production <c>DependencyMuxTagUpstreamSource</c> stamps.
/// <para>This used to be <c>0x80340000</c> (<c>BadNotConnected</c>), a different Bad code from the
/// one production uses. That drift was invisible while <c>WorstInputStatus</c> skipped every
/// null-valued entry — any Bad code behaved the same. #481 made the specific code load-bearing (it
/// is how cold start is told apart from a driver reporting Bad), so a fake disagreeing with
/// production here would let every alarm with an unset input report Bad in tests and Good in the
/// real runtime, or vice versa.</para>
/// </summary>
public const uint StatusNeverPushed = 0x80320000u;
/// <summary>Reads the current value of a tag, or returns a never-pushed snapshot if not set.</summary>
/// <param name="path">The tag path to read.</param>
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
: new DataValueSnapshot(null, StatusNeverPushed, null, DateTime.UtcNow);
/// <summary>Subscribes an observer to tag changes for the given path.</summary>
/// <param name="path">The tag path to subscribe to.</param>
@@ -1103,6 +1103,75 @@ public sealed class ScriptedAlarmEngineTests
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
// -------------------------------------------------------------------------
// #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3)
// -------------------------------------------------------------------------
/// <summary>
/// A driver reporting Bad with no value counts as Bad quality.
/// <para>This is the hole #478 Finding 3 recorded. <c>WorstInputStatus</c> skipped every entry whose
/// value was null as "no data yet", which is indistinguishable from a real fault when the fault
/// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the
/// case being skipped was the case that mattered most. Before the fix this test sees no
/// QualityChanged at all.</para>
/// </summary>
[Fact]
public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value.
up.Push("Temp", null, 0x80310000u); // BadNoCommunication
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u);
// The sub-code survives, so an operator can tell comms loss from any other fault.
q.WorstInputStatusCode.ShouldBe(0x80310000u);
}
/// <summary>
/// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed
/// does NOT drag the condition Bad.
/// <para>The falsifying control for the test above. Deleting the null-skip outright — the obvious
/// way to make a null-valued Bad count — would also make every not-yet-published input count, which
/// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its
/// first push, flooding the quality path with load-time annotations. Both tests pass only if the
/// engine keys off the never-pushed status code specifically.</para>
/// </summary>
[Fact]
public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
// "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and
// gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has.
using var eng = Build(up, out _);
// Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a
// CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it
// makes this assertion vacuous, and the first draft of this test was exactly that: it passed
// against the broken behaviour because the flash had already happened by the time it was listening.
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")],
TestContext.Current.CancellationToken);
up.Push("Temp", 60, 0u);
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
@@ -45,12 +45,6 @@ public sealed class DriverHostTests
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
@@ -248,13 +242,6 @@ public sealed class DriverHostTests
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
@@ -187,11 +187,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Discovers the address space by throwing an exception.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
@@ -224,11 +219,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="ct">Cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Discovers the address space and registers alarm conditions.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
@@ -573,18 +573,6 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
/// <summary>Verifies that FlushOptionalCachesAsync clears the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_clears_template_cache()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
}
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
@@ -210,9 +210,9 @@ public sealed class AbCipFetchUdtShapeTests
shape.ShouldBeNull();
}
/// <summary>Verifies that FlushOptionalCachesAsync empties the template cache.</summary>
/// <summary>Verifies that a cleared template cache is re-populated from the network.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
public async Task A_cleared_template_cache_refetches_from_the_controller()
{
var factory = new FakeTemplateReaderFactory
{
@@ -230,7 +230,10 @@ public sealed class AbCipFetchUdtShapeTests
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
// Gitea #525 deleted AbCipDriver.FlushOptionalCachesAsync, which is what used to clear this;
// the cache's own Clear() is now the only reset, and nothing in production calls it (see the
// AbCipTemplateCache remarks). What this test still pins is the cache-miss path below.
drv.TemplateCache.Clear();
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
@@ -174,9 +174,6 @@ public sealed class GalaxyDriverFactoryTests
await driver.ShutdownAsync(CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
driver.GetMemoryFootprint().ShouldBe(0);
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
@@ -10,8 +10,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate)
/// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate).
/// Driver-level infrastructure tests for finding Driver.Galaxy-007 (dispose-CTS gate).
/// Driver.Galaxy-011 (a non-zero GetMemoryFootprint estimate) was also covered here until
/// Gitea #525 deleted that member from IDriver as unconsumed surface.
/// </summary>
public sealed class GalaxyDriverInfrastructureTests
{
@@ -22,47 +23,11 @@ public sealed class GalaxyDriverInfrastructureTests
new GalaxyReconnectOptions());
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
/// <summary>Verifies that memory footprint is zero when there are no subscriptions.</summary>
[Fact]
public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
driver.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that memory footprint is nonzero after subscriptions are active.</summary>
[Fact]
public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
{
// When subscriptions are active the footprint estimate must be > 0 so the
// server's memory-pressure mechanism sees the Galaxy driver as a participant.
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
}
/// <summary>Verifies that memory footprint decreases after unsubscribing.</summary>
[Fact]
public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
var handle = await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
var afterSubscribe = driver.GetMemoryFootprint();
afterSubscribe.ShouldBeGreaterThan(0L);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsubscribe = driver.GetMemoryFootprint();
afterUnsubscribe.ShouldBeLessThan(afterSubscribe,
"footprint must decrease when subscriptions are removed");
}
//
// Three tests lived here asserting the footprint tracked the subscription registry. The member
// was deleted from IDriver in Gitea #525 as unconsumed surface — nothing ever read the number —
// and they went with it. The registry sizes they leaned on are still covered directly by the
// subscribe/unsubscribe tests below.
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
@@ -585,17 +585,21 @@ public sealed class MTConnectDiscoverTests
}
/// <summary>
/// …and after <see cref="MTConnectDriver.FlushOptionalCachesAsync"/> has dropped that cache,
/// discovery re-fetches and still streams the full tree. This is the whole reason discovery
/// must go through <c>GetOrFetchProbeModelAsync</c> rather than reading the cached field: a
/// memory-budget flush must never leave browse permanently answering "this agent has no data
/// items".
/// …and after the cache has been dropped, discovery re-fetches and still streams the full
/// tree. This is the whole reason discovery must go through <c>GetOrFetchProbeModelAsync</c>
/// rather than reading the cached field: a drop must never leave browse permanently
/// answering "this agent has no data items".
/// <para>Drives <see cref="MTConnectDriver.InvalidateProbeModel"/> — the Agent-restart drop,
/// which is the only thing that empties this cache in production. It used to drive
/// <c>FlushOptionalCachesAsync</c>, deleted with the rest of the unconsumed memory-pressure
/// surface in Gitea #525; that path had no production caller, so this now tests a case that
/// actually occurs.</para>
/// </summary>
[Fact]
public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
public async Task Discover_refetches_the_probe_model_after_the_cache_is_dropped()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
var cap = await DiscoverAsync(driver);
@@ -689,7 +693,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
client.ProbeFailure = new HttpRequestException("agent down");
var builder = new CapturingBuilder();
@@ -705,7 +709,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
@@ -590,31 +590,21 @@ public sealed class MTConnectDriverLifecycleTests
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
// ---- footprint + flush ----
// ---- dropping the probe cache ----
//
// These used to drive FlushOptionalCachesAsync. Gitea #525 deleted it as an unconsumed IDriver
// member, along with GetMemoryFootprint and the footprint test that only existed to assert on
// it. What survives is InvalidateProbeModel — the Agent-restart drop, which is the only thing
// that empties this cache in production, and which has the same retired-session guard and the
// same "browse must not be left permanently empty" obligation.
[Fact]
public async Task Memory_footprint_grows_when_initialize_populates_the_caches()
public void Dropping_the_probe_cache_keeps_the_observation_index()
{
var (driver, _) = NewDriver();
var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
var before = driver.GetMemoryFootprint();
await driver.InitializeAsync("{}", CancellationToken.None);
var after = driver.GetMemoryFootprint();
driver.InvalidateProbeModel();
// Before init the only config-attributable state is the two authored tags.
before.ShouldBeLessThan(after);
after.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
{
var (driver, _) = await InitializedDriverAsync();
var before = driver.GetMemoryFootprint();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeLessThan(before);
driver.CachedProbeModel.ShouldBeNull();
// The index is required for correctness (it IS the read surface) — never flushable.
@@ -624,12 +614,12 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
public async Task Dropping_the_cache_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
{
// Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
// cache-budget breach would permanently disable browse on that driver instance.
// Task 12's DiscoverAsync reads the probe model. If dropping it left no way back, an Agent
// restart would permanently disable browse on that driver instance.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
client.ProbeCallCount.ShouldBe(1);
@@ -679,7 +669,7 @@ public sealed class MTConnectDriverLifecycleTests
// it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
// the caller already handles "not connected" and should have exactly one failure mode.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
// Held locally: the gate is one-shot, so the client has already cleared its own reference by
// the time the request is parked on it.
@@ -712,7 +702,7 @@ public sealed class MTConnectDriverLifecycleTests
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
var client = built[0];
var staleModel = client.Probe;
@@ -742,22 +732,22 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
public async Task Dropping_the_cache_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
{
// Core polls the footprint and asks for a flush on its own schedule, so a flush arriving
// after the driver was torn down is ordinary, not exotic. It must observe the retired
// session and do nothing — publishing a "flushed" copy of it would reinstate a session
// holding an already-disposed client for every later caller.
// The /sample pump can see an Agent restart at any moment, so a drop arriving after the
// driver was torn down is ordinary, not exotic. It must observe the retired session and do
// nothing — publishing a "dropped" copy of it would reinstate a session holding an
// already-disposed client for every later caller.
//
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has
// no await point, so nothing can be interleaved between its read and its publish from a
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. The drop
// has no await point, so nothing can be interleaved between its read and its publish from a
// single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
// lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
// fetch-side CAS, which does have an await point, is covered by the test above.
var (driver, client) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
@@ -440,8 +440,8 @@ public sealed class MTConnectHostAndRediscoverTests
/// <summary>
/// <b>The probe is a liveness check, never a cache writer.</b> Publishing its answer into
/// <c>AgentSession.ProbeModel</c> would put a second writer on the field the lifecycle owns
/// under a compare-and-swap, and would silently re-warm a cache that
/// <c>FlushOptionalCachesAsync</c> (or an Agent restart) deliberately dropped.
/// under a compare-and-swap, and would silently re-warm a cache that an Agent restart
/// deliberately dropped.
/// </summary>
[Fact]
public async Task Probe_ticks_never_publish_into_the_session_probe_cache()
@@ -457,10 +457,9 @@ public sealed class MTConnectHostAndRediscoverTests
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeSameAs(original);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0);
// …and a flushed cache stays flushed until a real consumer asks for it.
await driver.FlushOptionalCachesAsync(Ct);
// …and a dropped cache stays dropped until a real consumer asks for it.
driver.InvalidateProbeModel();
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeNull();
}
@@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <c>ProjectReference</c> re-added but never used would not trip it (the compiler emits no
/// reference), so the csproj comment remains the primary record of the decision.
/// </para>
/// <para>
/// <b>That gap is now covered</b> by <c>DriverBrowserLayeringTests</c> (Commons.Tests), which
/// scans the csproj graph of every <c>*.Browser</c> project in the tree — added for Gitea #526,
/// after the identical violation was found sitting in <c>Driver.Sql.Browser</c> with no guard
/// watching it, because this file was written for the one driver that had an issue filed. What
/// stays here is the MQTT-specific half: the <c>.Transport</c> leaf shape and the
/// MQTTnet-free-ness of <c>.Contracts</c>.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttBrowserLayeringTests
@@ -159,24 +159,6 @@ public sealed class MqttDriverDiscoveryTests
results.ShouldBeEmpty();
}
/// <summary>
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
/// </summary>
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
@@ -334,14 +316,6 @@ public sealed class MqttDriverDiscoveryTests
fired.ShouldBe(0);
}
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
@@ -13,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <item>Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError</item>
/// <item>Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16</item>
/// <item>Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning</item>
/// <item>Driver.OpcUaClient-013 — GetMemoryFootprint and FlushOptionalCachesAsync contract</item>
/// <item>Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion</item>
/// </list>
/// </summary>
@@ -97,39 +96,11 @@ public sealed class OpcUaClientMediumFindingsRegressionTests
}
// ---- Driver.OpcUaClient-013 ----
/// <summary>Verifies that GetMemoryFootprint returns zero before discovery.</summary>
[Fact]
public void GetMemoryFootprint_returns_zero_before_first_discovery()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-mem-013");
// Pre-init: no nodes discovered yet, so footprint is 0.
drv.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that FlushOptionalCachesAsync completes without throwing.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_completes_without_throwing()
{
// FlushOptionalCachesAsync is a cheap no-op before any discovery. Must not throw.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush-013");
await Should.NotThrowAsync(async () =>
await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken));
}
/// <summary>Verifies that FlushOptionalCachesAsync resets the footprint counter.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_resets_footprint_counter()
{
// GetMemoryFootprint uses _discoveredNodeCount * 512. FlushOptionalCachesAsync resets
// _discoveredNodeCount to 0, so a subsequent call to GetMemoryFootprint returns 0.
// We drive this through a complete Discovery pass against a stub — instead, test the
// contract via FlushOptionalCachesAsync clearing a counter we can observe.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush2-013");
drv.GetMemoryFootprint().ShouldBe(0L, "pre-flush should be 0 (no discovery)");
await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
drv.GetMemoryFootprint().ShouldBe(0L, "post-flush should still be 0");
}
//
// Three tests lived here covering GetMemoryFootprint + FlushOptionalCachesAsync. Both members
// were deleted from IDriver in Gitea #525 as unconsumed surface, and these went with them. Worth
// noting one was vacuous anyway: it asserted a footprint of 0 before the flush and 0 after, with
// no discovery in between, so it would have passed against a flush that did nothing at all.
// ---- Driver.OpcUaClient-015: pure-logic paths ----
@@ -25,6 +25,13 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!-- SqlDriverFormContractTests asserts the AdminUI form's emitted JSON against the runtime
factory that reads it, so it genuinely needs the runtime driver. It used to arrive here
transitively through AdminUI -> Driver.Sql.Browser -> Driver.Sql; Gitea #526 cut that
edge, which is what surfaced this dependency as the implicit one it always was. A TEST
project referencing a runtime driver is fine — tests are not in the AdminUI's deploy
graph, which is the whole thing #526 was about. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
</ItemGroup>
</Project>
@@ -155,14 +155,4 @@ public sealed class FakeReconnectDriver : IDriver
public DriverHealth GetHealth() => _reconnecting
? new DriverHealth(DriverState.Reconnecting, _lastSuccess, null)
: new DriverHealth(DriverState.Healthy, _lastSuccess, null);
/// <summary>Returns a zero memory footprint (the fake holds no driver-attributable caches).</summary>
/// <returns>Always <c>0</c>.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches — a no-op (the fake holds none).</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
}
@@ -151,6 +151,94 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
// ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------
//
// #478 gave a scripted condition the worst quality of its inputs, but only for inputs whose driver
// PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it
// raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value and
// went on evaluating it. These pin the synthetic Bad that closes that hole.
/// <summary>Comms loss pushes a Bad for every value ref the driver owns — not one, and not the
/// alarm-bearing ones (those take the Part 9 quality path instead).</summary>
[Fact]
public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
var pushes = new[]
{
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5)),
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5)),
};
pushes.Select(p => p.FullReference).ShouldBe(
new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true);
foreach (var p in pushes)
{
// BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code
// names the actual cause and reaches a client through the scripted condition's quality.
p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Null rather than the last-known value: the honest statement is "no current value". The
// engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null.
p.Value.ShouldBeNull();
p.DriverInstanceId.ShouldBe("drv-1");
}
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Reconnect pushes NOTHING — and this is the falsifying control for the test above, since
/// both drive the same handler over the same populated map and only one may produce traffic.</summary>
[Fact]
public void Driver_reconnect_pushes_nothing_into_the_mux()
{
// Publishing Good here would assert a value quality the host has not observed. Recovery instead
// rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription,
// so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with
// forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll
// engine would compare against retained LastValues, see an unchanged value, raise nothing, and
// strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert.
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is
/// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags.</summary>
[Fact]
public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
@@ -266,10 +266,6 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
@@ -395,8 +395,6 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -335,9 +335,5 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -426,10 +426,6 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -289,10 +289,5 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -314,10 +314,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>A driver that implements <see cref="ISubscribable"/> + <see cref="IAlarmSource"/> and lets the
@@ -241,10 +241,5 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -129,8 +129,6 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
@@ -205,10 +205,6 @@ public sealed class DriverInstanceActorWriteAndSubscribeTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Writes the specified requests (never reached — actor fast-fails in Connecting).</summary>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -63,11 +63,6 @@ internal class StubDriver : IDriver
}
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
@@ -40,6 +41,36 @@ public sealed class DependencyMuxTagUpstreamSourceTests
snap.StatusCode.ShouldNotBe(0u);
// Timestamp comes from the injected clock.
snap.ServerTimestampUtc.ShouldBe(fixedNow);
// #481: the SPECIFIC code is load-bearing, not merely "some Bad". ScriptedAlarmEngine skips
// exactly this code when computing worst-of-input quality, which is how it tells a path nothing
// has ever pushed to apart from a driver reporting Bad with no value — the shape a comms-lost
// driver produces, and previously indistinguishable from cold start. Any other Bad code here
// would make every not-yet-published input drag its condition Bad at deploy.
snap.StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
}
/// <summary>
/// The engine's private copy of the never-pushed sentinel agrees with the one this source stamps.
/// </summary>
/// <remarks>
/// <c>Core.ScriptedAlarms</c> deliberately does not reference <c>Commons</c>, so the engine inlines
/// the constant rather than sharing <see cref="OpcUaStatus"/>. The value is fixed by the OPC UA spec
/// so it cannot legitimately drift, but a typo in either copy would silently un-wire #481: the
/// engine would stop recognising the placeholder and every cold-start input would report Bad. Read
/// by reflection because the engine's copy is private — deliberately, since nothing outside the
/// engine should key off it.
/// </remarks>
[Fact]
public void The_engine_agrees_with_this_source_on_the_never_pushed_status_code()
{
var engineConst = typeof(Core.ScriptedAlarms.ScriptedAlarmEngine)
.GetField("StatusNeverPushed", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
.ShouldNotBeNull("ScriptedAlarmEngine.StatusNeverPushed is gone or was renamed — #481's cold-start "
+ "vs. reported-Bad distinction is wired through it, so this guard must be repointed, not deleted");
engineConst.GetRawConstantValue().ShouldBe(
new DependencyMuxTagUpstreamSource().ReadTag("never/pushed").StatusCode);
}
[Fact]