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