diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index c5a50698..0d9e5f18 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -32,6 +32,7 @@ + diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index ce5e3c63..2dd68cfd 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -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`, diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index 16e9c496..5bfd6186 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -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. --- diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 0a62c406..2f9594a2 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -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. diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 1241876d..7cbcdfe6 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -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. diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md index 0aa81f58..62a13701 100644 --- a/docs/plans/2026-07-15-sql-poll-driver-design.md +++ b/docs/plans/2026-07-15-sql-poll-driver-design.md @@ -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). | diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md index b18c185e..c5be713d 100644 --- a/docs/v2/driver-stability.md +++ b/docs/v2/driver-stability.md @@ -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. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index 193b2381..02c353fc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -64,26 +64,5 @@ public interface IDriver /// has a running / not-running distinction worth reporting. /// The driver's current health snapshot. DriverHealth GetHealth(); - - /// - /// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables). - /// ⚠️ Nothing calls this (Gitea #525). Its only consumer was MemoryTracking, - /// 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 0 is equally correct today. Do not - /// read a value from it and act on it without first building a consumer that is actually wired. - /// - /// The approximate memory footprint in bytes. - long GetMemoryFootprint(); - - /// - /// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget. - /// Required-for-correctness state must NOT be flushed. - /// ⚠️ Nothing calls this (Gitea #525) — same reason as - /// : the memory-pressure path that would have invoked it is - /// gone. - /// - /// Cancellation token for the operation. - /// A task that represents the asynchronous operation. - Task FlushOptionalCachesAsync(CancellationToken cancellationToken); } + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 250953d9..ff3582fd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; /// public sealed class ScriptedAlarmEngine : IDisposable { + /// + /// OPC UA Part 4 BadWaitingForInitialData — the status the upstream source stamps on a + /// path nothing has ever pushed to, and the marker uses to tell + /// genuine cold start apart from a driver reporting Bad. + /// Inlined rather than taken from Commons.OpcUa.OpcUaStatus because this project + /// deliberately does not reference Commons — the engine is pure evaluation logic over + /// Core.Abstractions. The value is fixed by the OPC UA spec, so the duplication cannot + /// drift; DependencyMuxTagUpstreamSourceTests pins the two agreeing. + /// + 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); /// 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 - /// 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). + /// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which + /// the 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). + /// "Never pushed" is a specific status code, not "the value is null" (#481, closing #478 + /// Finding 3). 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 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. private static uint WorstInputStatus(IReadOnlyDictionary 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) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 05ad2904..703579d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1017,15 +1017,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public DriverHealth GetHealth() => _health; - /// - public long GetMemoryFootprint() => 0; - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) - { - _templateCache.Clear(); - return Task.CompletedTask; - } // ---- ITagDiscovery ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs index 719cd87f..81cda88d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs @@ -4,9 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// Cache of UDT shape descriptors keyed by (deviceHostAddress, templateInstanceId). -/// Populated on demand during discovery + whole-UDT reads; flushed via -/// and on device -/// ReinitializeAsync. +/// Populated on demand during discovery + whole-UDT reads. +/// Nothing in production clears it. Its only caller was +/// AbCipDriver.FlushOptionalCachesAsync, deleted in Gitea #525 as an unconsumed +/// IDriver 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 +/// ReinitializeAsync 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. /// /// /// Templates are decoded by (CIP Template Object @@ -34,7 +38,8 @@ public sealed class AbCipTemplateCache public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) => _shapes[(deviceHostAddress, templateInstanceId)] = shape; - /// Drop every cached shape — called on . + /// Drop every cached shape. Retained as ordinary cache API + the tests' reset seam; + /// see the type remarks for why no production path calls it. public void Clear() => _shapes.Clear(); /// Count of cached shapes — exposed for diagnostics + tests. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index faadaafc..65b2d068 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -229,11 +229,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public DriverHealth GetHealth() => _health; - /// - public long GetMemoryFootprint() => 0; - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// Gets the device count. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs index 2d94ea25..61e505d1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs @@ -279,16 +279,6 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba ? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null) : new DriverHealth(DriverState.Unknown, _lastComputeUtc, null); - /// - public long GetMemoryFootprint() => 0; - - /// - 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 ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index b01ef4a4..ebd68e38 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -311,10 +311,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public DriverHealth GetHealth() => Volatile.Read(ref _health); - /// - public long GetMemoryFootprint() => 0; - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Gets the number of configured devices. internal int DeviceCount => _devices.Count; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index f77d55f6..dd9e7225 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -680,22 +680,6 @@ public sealed class GalaxyDriver /// public IReadOnlyList GetHostStatuses() => _hostStatuses.Snapshot(); - /// - 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); - } - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; // ===== ITagDiscovery (PR 4.1) ===== diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 53df61f5..e811179c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -62,21 +62,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable { - /// - /// Coarse per-entry byte weights behind . The contract asks - /// for an approximate 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. - /// - private const int ProbeModelBytesPerDataItem = 512; - - /// Estimated retained bytes per live dataItemId → snapshot entry. - private const int IndexBytesPerEntry = 256; - - /// Estimated retained bytes per authored . - private const int TagBytesPerEntry = 256; - /// /// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct /// from the index's BadNoCommunication, which means the Agent answered and said the @@ -323,15 +308,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// /// /// - /// Why one field instead of three. These three values are read outside - /// — by , by - /// (Task 12's browse), and by - /// — while every write happens under it. As three - /// separate fields they could be observed mid-update: a reader could see a dropped - /// ProbeModel 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. + /// Why one field instead of several. These values are read outside + /// — by and by + /// (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 — GetMemoryFootprint — along with the rest of that dead surface.) /// /// /// Why not simply take the lock in those readers. Both readers await the @@ -423,8 +407,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// /// The cached /probe device model, or null when it has not been fetched or was - /// dropped by . Prefer - /// , which cannot observe the flushed hole. + /// dropped by on an Agent restart. Prefer + /// , which cannot observe the dropped hole. /// internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel; @@ -596,58 +580,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// public DriverHealth GetHealth() => ReadHealth(); - /// - /// - /// Sums the three caches that scale with plant size: the /probe device model (by - /// declared data item), the live observation index (by indexed data item), and the authored - /// tag table. See for why the weights are coarse - /// constants. - /// - public long GetMemoryFootprint() => - ((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem) - + ((long)ObservationIndex.Count * IndexBytesPerEntry) - + ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry); - - /// - /// - /// Drops the /probe 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 - /// , which re-fetches and re-caches on a miss. - /// - /// The drop is a single of a whole new - /// , so the model and its data-item count can never be seen - /// out of step (which would skew ), and a flush that - /// races a concurrent teardown or re-initialize is discarded rather than resurrecting - /// the session it was reading. - /// - /// - 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; - } - /// /// /// @@ -963,9 +895,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// /// /// The model comes from , never from - /// . 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. + /// . 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. /// /// /// This throws, and that is the opposite of 's posture on @@ -1250,8 +1182,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// /// Publishing the answer into 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 - /// — or an Agent restart, see + /// compare-and-swap, and would silently re-warm a cache that an Agent restart — see /// — deliberately dropped. The parsed model is /// discarded; only the fact that a request completed is used. /// @@ -1473,9 +1404,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// , which reads /// ; 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 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. /// /// /// Once per change, not once per chunk. Every chunk after a restart carries the new @@ -1514,12 +1445,16 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis } /// - /// Drops the cached /probe device model so the next consumer re-fetches it. Same - /// compare-and-swap discipline as — 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 /probe device model so the next consumer re-fetches it. + /// internal rather than private so the discovery tests can exercise the real + /// drop path; production calls it from . + /// Compare-and-swap, and retried — 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, FlushOptionalCachesAsync, 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.) /// - 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. /// /// - /// Compare-and-swap for the same reason uses one: + /// Compare-and-swap for the same reason uses one: /// a lifecycle change landing mid-update has newer state, so this write is dropped /// rather than replayed over it. /// @@ -2221,7 +2156,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// The /probe device model: the cached copy when warm, otherwise a fresh /// deadline-bounded /probe that re-populates the cache. This is the accessor every /// model consumer (Task 12's DiscoverAsync) must use, so that - /// can never leave browse permanently disabled. + /// can never leave browse permanently disabled. /// /// /// Lock-free by design — see the 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; - /// - /// 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 poll. - /// - private static int CountDataItems(MTConnectProbeModel model) => - model.Devices.Sum(d => d.AllDataItems().Count()); - /// /// 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 /// /// The live agent client and the /probe cache derived from it, as one immutable /// unit. Immutable so that publishing a change is a single reference swap — see the - /// remarks for why the three values must move together. + /// remarks for why these values must move together. /// /// The agent client this session owns and will dispose on teardown. /// - /// The cached device model, or null once has + /// The cached device model, or null once has /// dropped it. Never null at the moment a session is first published. /// - /// - /// Data items declared by , counted once at cache time and - /// always 0 when it is null. - /// /// /// The Agent's instanceId as last observed. Updated in place (by /// ) 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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 3d972795..08b845cd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -282,10 +282,6 @@ public sealed class ModbusDriver /// Barrier-protected publish of a new _health snapshot. /// private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value); - /// - public long GetMemoryFootprint() => 0; - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; // ---- ITagDiscovery ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs index e8826724..7c17e2fc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -88,15 +88,6 @@ public sealed class MqttDriver /// The publish failed, or the driver has no live broker connection to publish through. private const uint StatusBadCommunicationError = 0x80050000u; - /// - /// Rough per-tag driver-attributable cost: the parsed (five - /// strings — RawPath, topic, JSONPath and the record header) plus its cached - /// and the two dictionary entries that index it. An estimate - /// by construction — GetMemoryFootprint exists to drive a cache-budget decision, not - /// to be exact. - /// - private const long ApproxBytesPerAuthoredTag = 512; - /// /// The single folder streams every variable under — and therefore /// the only subtree path a rediscovery ScopeHint can name truthfully. See @@ -429,24 +420,6 @@ public sealed class MqttDriver }; } - /// - /// 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. - /// - /// The approximate memory footprint in bytes. - public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag; - - /// - /// No-op, deliberately. This driver holds no optional cache: the authored table is the - /// address space itself, and the last-value cache is — 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. - /// - /// Unused; present for the shape. - /// A completed task. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; // ---- ITagDiscovery ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 26dece00..10730d77 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -129,12 +129,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// The that owns . private CertificateValidator? _certValidatorRef; /// - /// Approximate count of discovered nodes (folders + variables). Updated by - /// and used to report a non-zero - /// to the Core allocation-slope detector. - /// - private volatile int _discoveredNodeCount; - /// /// 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 /// public DriverHealth GetHealth() => _health; - /// - // 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; - - /// - // 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(); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index c7949bb4..02c3e8d7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -328,9 +328,6 @@ public sealed class S7Driver /// public DriverHealth GetHealth() => _health; - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; - /// /// True when carries a real config body. The /// bootstrapper always passes a populated document; some unit tests pass "{}" or @@ -481,11 +478,6 @@ public sealed class S7Driver /// private static readonly HashSet UnimplementedDataTypes = new(); - /// - // 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 ---- /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs index bd43f089..2b0bd7a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -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 /// /// - /// Sourced from , the driver's interim local constant, rather - /// than from DriverTypeNames — the shared constant set is added by the driver-factory task, - /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is - /// registered against it. Repointing that one constant repoints this too. + /// Sourced from the shared set, which is what the driver's own + /// SqlDriver.DriverTypeName 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 + /// (else DriverTypeNamesGuardTests goes red). One exists + /// now, and reading the shared constant is also what lets this browser stay off the runtime driver + /// — SqlDriver is the only type it still needed from there (Gitea #526). /// - public string DriverType => SqlDriver.DriverTypeName; + public string DriverType => DriverTypeNames.Sql; /// The environment variable a connectionStringRef resolves through. /// The authored reference name. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj index daa01255..127b7caa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj @@ -11,15 +11,34 @@ - - + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs similarity index 97% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs index 13f26329..31dae6ef 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// . Any code path that builds SQL by concatenating an authored tag field /// without going through is a defect (design §8.1). /// Quoting is the backstop, not the only defence. Design §8.1's catalog gate is -/// implemented: resolves every authored table/column against the live +/// implemented: SqlCatalogGate resolves every authored table/column against the live /// catalog at Initialize and replaces it with the catalog's own spelling, so an identifier /// reaching on the poll path is a string this driver read back out of /// / / — not @@ -96,7 +96,7 @@ public interface ISqlDialect /// principal — T-SQL's SELECT SCHEMA_NAME(). Takes no parameters. /// /// - /// Needed by : a tag authored as TagValues rather than + /// Needed by SqlCatalogGate: a tag authored as TagValues rather than /// dbo.TagValues has to be looked up in some schema, and guessing dbo 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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs similarity index 98% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index ca38e8bb..1f9b4c5b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect /// The rejection messages deliberately do not echo the offending value — it is untrusted /// input and this exception's text reaches the driver log. /// This is a backstop, not the only defence. Design §8.1's catalog gate - /// () resolves an authored table/column against the live catalog at + /// (SqlCatalogGate) 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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj new file mode 100644 index 00000000..dd9f264b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj @@ -0,0 +1,30 @@ + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + + ZB.MOM.WW.OtOpcUa.Driver.Sql + ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect + + + + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index c69de1ec..f837285d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -397,14 +397,6 @@ public sealed class SqlDriver /// public DriverHealth GetHealth() => ReadHealth(); - /// No caches, no symbol table, no connection between polls — the footprint is the tag table. - /// Always zero. - public long GetMemoryFootprint() => 0; - - /// Nothing optional to flush: the authored table is required for correctness. - /// Cancellation token for the operation. - /// A completed task. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; // ---- ITagDiscovery ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj index 66ddb720..559f1ab6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj @@ -13,6 +13,9 @@ + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 4acdbdf2..e041066f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -208,18 +208,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// public DriverHealth GetHealth() => _health; - /// - // 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); - - /// - // 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; /// Gets the count of configured devices. internal int DeviceCount => _devices.Count; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor index b616571a..94a76250 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor @@ -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; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 978e4d9c..bd58ccf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -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), }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index 863f1552..fb713cf6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -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(), }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index a81f6b25..4b1727c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -41,8 +41,10 @@ + .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. --> diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 6d257dbd..950909b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -1009,9 +1009,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// alerts row is published here — driver comms health has its own status surface /// (); a row per condition would be alarm-fatigue. /// + /// + /// #481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency + /// mux. #478 gave a scripted condition the worst quality of its input tags, but only for + /// inputs whose driver actually publishes 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, WorstInputStatus stayed Good, and worse, + /// AreInputsReady stayed true, so the predicate went on evaluating a stale value after + /// the source had died. closes that by pushing a + /// synthetic Bad for every value ref the driver owns. + /// /// 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); } + /// + /// #481 (Layer 4) — push a synthetic 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. + /// + /// + /// + /// The ref set already existed. #481 asked for a driver → all-tag-refs map to be built; + /// is exactly that, keyed (DriverInstanceId, RawPath) and + /// rebuilt from the composition's RawTags ∪ UnsReferenceVariables on every apply. + /// Alarm-bearing tags live in the separate 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. + /// + /// + /// Value null, not the last-known value. The honest statement is "there is no + /// current value", and the engine's AreInputsReady gate holds the predicate on any Bad + /// regardless, so no script observes the null. It does mean the Bad must survive + /// WorstInputStatus, which until #481 skipped every null-valued entry as cold start — + /// hence the paired change stamping never-pushed paths BadWaitingForInitialData so the two + /// are tellable apart. Without that half this fan-out would be silently inert. + /// + /// + /// Nothing is pushed on reconnect, deliberately. Publishing Good would assert a value + /// quality this actor has not observed. Recovery rides the driver's own republish: + /// DriverInstanceActor calls DetachSubscription on the Connected → Reconnecting + /// edge, which nulls the subscription handle, 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 what + /// makes recovery work: without it the poll engine would compare against its retained + /// LastValues, 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. + /// + /// + /// 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. + /// + /// + /// The driver child that just reported comms loss. + 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; /// public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); - /// - public long GetMemoryFootprint() => 0; - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private void HandleRestartDriver(RestartDriver msg) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs index b58e6d7d..d0734f45 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs @@ -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; /// 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 diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs index 78f45228..d8fba909 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs @@ -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; } /// An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate. @@ -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; } /// Configurable IDriverFactory: delegates construction to a supplied func and records what it made. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs new file mode 100644 index 00000000..c8e70589 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs @@ -0,0 +1,152 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +/// +/// Pins the layering rule for every bespoke driver browser in the tree: a *.Browser +/// project must not reach its runtime *.Driver project, by any path. +/// +/// +/// +/// Why this exists rather than a per-driver test. Gitea #512 removed this violation from +/// the MQTT browser and pinned it with MqttBrowserLayeringTests. The identical violation was +/// sitting in Driver.Sql.Browser 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. +/// +/// +/// Why it reads csproj files rather than loaded assemblies. 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 ProjectReference, so an assembly-reference test — +/// which is what MqttBrowserLayeringTests does, and says so — cannot see that case. The two +/// are complementary: this one catches the reference, that one catches the usage. +/// +/// +/// The walk is transitive. 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 MqttBrowserLayeringTests guards for .Transport by hand. +/// +/// +[Trait("Category", "Unit")] +public sealed class DriverBrowserLayeringTests +{ + /// + /// 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. + /// + 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(); + + 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( + " 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)]; + + /// Absolute paths of the csproj files one project references directly. + private static IReadOnlyList 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)))) + ]; + } + + /// Assembly-ish names of every project reachable from . + private static HashSet TransitiveReferences(string csprojPath) + { + var reached = new HashSet(StringComparer.OrdinalIgnoreCase); + var pending = new Stack(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; + } + + /// + /// Walks up from the test binary until the solution file appears. The tests run out of + /// bin/Debug/net10.0, so no fixed relative depth is reliable across host layouts. + /// + private static readonly Lazy 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; + }); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs index 6ad0a3fc..61316199 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs @@ -38,11 +38,23 @@ public sealed class FakeUpstream : ITagUpstreamSource } } - /// Reads the current value of a tag, or returns a bad-status snapshot if not set. + /// + /// OPC UA BadWaitingForInitialData — the status a never-pushed path reports, matching what + /// the production DependencyMuxTagUpstreamSource stamps. + /// This used to be 0x80340000 (BadNotConnected), a different Bad code from the + /// one production uses. That drift was invisible while WorstInputStatus 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. + /// + public const uint StatusNeverPushed = 0x80320000u; + + /// Reads the current value of a tag, or returns a never-pushed snapshot if not set. /// The tag path to read. 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); /// Subscribes an observer to tag changes for the given path. /// The tag path to subscribe to. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs index 1748070a..12786bb0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -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) + // ------------------------------------------------------------------------- + + /// + /// A driver reporting Bad with no value counts as Bad quality. + /// This is the hole #478 Finding 3 recorded. WorstInputStatus 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. + /// + [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(); + 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); + } + + /// + /// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed + /// does NOT drag the condition Bad. + /// 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. + /// + [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(); + 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 cond, int timeoutMs = 2000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs index 1f6ff319..98586ed2 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs @@ -45,12 +45,6 @@ public sealed class DriverHostTests public DriverHealth GetHealth() => new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - - /// Flushes optional caches asynchronously. - /// The cancellation token. - public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; } /// Verifies that registering a driver initializes it and tracks its health. @@ -248,13 +242,6 @@ public sealed class DriverHostTests /// Gets the current health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - - /// Flushes optional caches asynchronously. - /// The cancellation token. - public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; - private static readonly TaskCompletionSource CompletedTcs = MakeCompleted(); private static TaskCompletionSource MakeCompleted() { diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs index f2d71510..3ea21a63 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs @@ -187,11 +187,6 @@ public sealed class GenericDriverNodeManagerTests public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask; /// Gets the current health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - /// Flushes optional caches in the driver. - /// Cancellation token (unused in test double). - public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask; /// Discovers the address space by throwing an exception. /// The builder used to construct the address space. @@ -224,11 +219,6 @@ public sealed class GenericDriverNodeManagerTests public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; /// Gets the current health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - /// Flushes optional caches in the driver. - /// Cancellation token. - public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; /// Discovers the address space and registers alarm conditions. /// The builder used to construct the address space. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs index 8e860f9e..5be7525d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -573,18 +573,6 @@ public sealed class AbCipDriverDiscoveryTests cache.Count.ShouldBe(0); } - /// Verifies that FlushOptionalCachesAsync clears the template cache. - [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 ---- /// Test implementation of IAddressSpaceBuilder that records calls. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs index 2d0554a8..f56e5893 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs @@ -210,9 +210,9 @@ public sealed class AbCipFetchUdtShapeTests shape.ShouldBeNull(); } - /// Verifies that FlushOptionalCachesAsync empties the template cache. + /// Verifies that a cleared template cache is re-populated from the network. [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. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs index 2e3f6a3f..875dde8d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -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 } /// Verifies that reinitializing with equivalent config refreshes health. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs index f135218c..b455ebe2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs @@ -10,8 +10,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// -/// 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. /// public sealed class GalaxyDriverInfrastructureTests { @@ -22,47 +23,11 @@ public sealed class GalaxyDriverInfrastructureTests new GalaxyReconnectOptions()); // ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size ===== - - /// Verifies that memory footprint is zero when there are no subscriptions. - [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); - } - - /// Verifies that memory footprint is nonzero after subscriptions are active. - [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); - } - - /// Verifies that memory footprint decreases after unsubscribing. - [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 ===== diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs index 6e02e552..ea6eef6a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs @@ -585,17 +585,21 @@ public sealed class MTConnectDiscoverTests } /// - /// …and after has dropped that cache, - /// discovery re-fetches and still streams the full tree. This is the whole reason discovery - /// must go through GetOrFetchProbeModelAsync 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 GetOrFetchProbeModelAsync + /// rather than reading the cached field: a drop must never leave browse permanently + /// answering "this agent has no data items". + /// Drives — the Agent-restart drop, + /// which is the only thing that empties this cache in production. It used to drive + /// FlushOptionalCachesAsync, 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. /// [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(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs index b413261d..ef3dddfe 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs index 0b9e3e1b..fc96b5c3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs @@ -440,8 +440,8 @@ public sealed class MTConnectHostAndRediscoverTests /// /// The probe is a liveness check, never a cache writer. Publishing its answer into /// AgentSession.ProbeModel would put a second writer on the field the lifecycle owns - /// under a compare-and-swap, and would silently re-warm a cache that - /// FlushOptionalCachesAsync (or an Agent restart) deliberately dropped. + /// under a compare-and-swap, and would silently re-warm a cache that an Agent restart + /// deliberately dropped. /// [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(); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs index 79c36015..cc3f1a2f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs @@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// ProjectReference 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. /// +/// +/// That gap is now covered by DriverBrowserLayeringTests (Commons.Tests), which +/// scans the csproj graph of every *.Browser project in the tree — added for Gitea #526, +/// after the identical violation was found sitting in Driver.Sql.Browser 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 .Transport leaf shape and the +/// MQTTnet-free-ness of .Contracts. +/// /// [Trait("Category", "Unit")] public sealed class MqttBrowserLayeringTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs index 7a5f28df..ba49a6c1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs @@ -159,24 +159,6 @@ public sealed class MqttDriverDiscoveryTests results.ShouldBeEmpty(); } - /// - /// The falsifiability pin for the cache-flush contract: the last-value cache backs - /// IReadable, so flushing it would silently turn every subscribed node Bad under - /// memory pressure. FlushOptionalCachesAsync must leave it untouched. - /// - [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"); - } - /// A malformed reinitialize delta must never fault the driver, and must not lose the running config. [Fact] public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault() @@ -334,14 +316,6 @@ public sealed class MqttDriverDiscoveryTests fired.ShouldBe(0); } - /// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes. - [Fact] - public void GetMemoryFootprint_TracksAuthoredTable() - { - PlainDriver().GetMemoryFootprint().ShouldBe(0); - PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0); - } - /// Shutdown on a never-initialized driver is a no-op, not a null-reference. [Fact] public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs index ea8f9221..6da77610 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs @@ -13,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError /// Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16 /// Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning -/// Driver.OpcUaClient-013 — GetMemoryFootprint and FlushOptionalCachesAsync contract /// Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion /// /// @@ -97,39 +96,11 @@ public sealed class OpcUaClientMediumFindingsRegressionTests } // ---- Driver.OpcUaClient-013 ---- - - /// Verifies that GetMemoryFootprint returns zero before discovery. - [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); - } - - /// Verifies that FlushOptionalCachesAsync completes without throwing. - [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)); - } - - /// Verifies that FlushOptionalCachesAsync resets the footprint counter. - [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 ---- diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 4ccd43cf..88277f85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -25,6 +25,13 @@ + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs index ceec98f6..a2ff9911 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs @@ -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); - - /// Returns a zero memory footprint (the fake holds no driver-attributable caches). - /// Always 0. - public long GetMemoryFootprint() => 0; - - /// Flushes optional caches — a no-op (the fake holds none). - /// Cancellation token for the operation. - /// A completed task. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) - => Task.CompletedTask; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index a445fe22..df113425 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -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. + + /// 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). + [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(TimeSpan.FromSeconds(5)), + mux.ExpectMsg(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)); + } + + /// 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. + [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)); + } + + /// 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. + [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)); + } + /// 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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs index 65fc6e57..f3d75523 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs @@ -266,10 +266,6 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest 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 SubscribeAlarmsAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index eb0e4e5c..e16e1b7c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -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> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs index 56a85fb8..c3c0d672 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs @@ -335,9 +335,5 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase 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; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index baf11d1a..f94acd8f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -426,10 +426,6 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase /// public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); /// - public long GetMemoryFootprint() => 0; - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; - /// public Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs index 31d0da91..e4b77e51 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs @@ -289,10 +289,5 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase /// public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null); - /// - public long GetMemoryFootprint() => 0; - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs index 450fafee..e1f22d7c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs @@ -314,10 +314,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Gets the health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - /// Flushes optional caches in the driver. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// A driver that implements + and lets the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs index 36b2651d..0154d205 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs @@ -241,10 +241,5 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest /// public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError); - /// - public long GetMemoryFootprint() => 0; - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs index 87aa6167..745f3f6b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs @@ -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> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs index 4941eb45..38d27fea 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs @@ -205,10 +205,6 @@ public sealed class DriverInstanceActorWriteAndSubscribeTests : RuntimeActorTest public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Gets the health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - /// Flushes optional caches in the driver. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Writes the specified requests (never reached — actor fast-fails in Connecting). public Task> WriteAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index b9780be4..ea8c5c53 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -63,11 +63,6 @@ internal class StubDriver : IDriver } /// Gets the health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); - /// Gets the memory footprint of the driver. - public long GetMemoryFootprint() => 0; - /// Flushes optional caches in the driver. - /// Cancellation token for the operation. - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs index d26b5381..d020d118 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs @@ -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); + } + + /// + /// The engine's private copy of the never-pushed sentinel agrees with the one this source stamps. + /// + /// + /// Core.ScriptedAlarms deliberately does not reference Commons, so the engine inlines + /// the constant rather than sharing . 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. + /// + [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]