refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)

Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. 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. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` 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.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 09:07:11 -04:00
parent 803f4e6efe
commit b815446940
42 changed files with 130 additions and 523 deletions
+23 -7
View File
@@ -136,15 +136,31 @@ the real per-capability timeout / retry / breaker policies, resolved via
Every driver runs Tier A because no factory passes a tier, so today that means
one uniform policy set.
⚠️ Two `IDriver` members are now **consumerless**: `GetMemoryFootprint()` and
`FlushOptionalCachesAsync()` existed to feed `MemoryTracking`. All 12 drivers
still implement them and nothing calls them. They were left in place rather
than swept, since removing them touches every driver and every test stub —
tracked as Gitea **#525**.
`IDriver` **no longer carries `GetMemoryFootprint()` or
`FlushOptionalCachesAsync()`** (Gitea **#525**). They existed to feed
`MemoryTracking`, deleted with the rest of the driver-tier recycle machinery in
**#522**; after that nothing called them, and their doc-comments still claimed
Core polled the footprint every 30 s, which had not been true since v3. The
sweep removed them from the interface, all 12 drivers, and every test stub,
along with the support code that existed only to feed them — MTConnect's
per-entry byte weights and its cached data-item count, OpcUaClient's
`_discoveredNodeCount`, and MQTT's per-tag byte estimate.
Two consequences worth knowing:
- `AbCipTemplateCache` now has **no production reset path**. Its only caller was
AB CIP's flush — which itself had no caller, so nothing has actually changed
in behaviour, only in how visible the fact is. A UDT redefined on a live
controller keeps its stale shape until the driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
Agent restart. The tests that used to reach it through the flush now drive
that path instead, so they cover a case that actually occurs.
If per-driver memory protection is wanted again, build it for the architecture
as it is now: a process-wide watchdog, not a per-driver tier whose only remedy
requires a supervisor that cannot exist in-process.
as it is now: a process-wide watchdog that can ask drivers to flush, not a
per-driver tier whose only remedy requires a supervisor that cannot exist
in-process. The deleted per-driver implementations are in git history and were
mostly reusable — what was always missing is the trigger.
---
+1 -1
View File
@@ -168,6 +168,6 @@ UDT-typed tags found in the walk.
## Operational Notes
- **Native heap is invisible to the GC.** `GetMemoryFootprint()` reports CLR allocations only; libplctag's native `Tag` heap does not show up there. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+1 -1
View File
@@ -132,6 +132,6 @@ and the UI authoring flow.
## Operational Notes
- **Native heap is invisible to the GC.** As with AB CIP, `GetMemoryFootprint()` reports CLR allocations only; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles.
- **Native heap is invisible to the GC.** As with AB CIP, libplctag's native heap does not show up in CLR allocation figures; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles. (`IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)).
- **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands.
+2
View File
@@ -120,6 +120,8 @@ For Tier A/B (in-process) drivers, the "crash" being counted is a driver-instanc
In-process drivers cannot be recycled by killing the server process — that would take down every other driver, every session, and the OPC UA endpoint. RSS watchdogs and scheduled recycle therefore do **not** apply to Tier A/B. Instead, each driver instance is monitored at a finer grain:
> ⚠️ **The two bullets below were never built as described, and the surface they name is now gone.** `GetMemoryFootprint()` / `FlushOptionalCachesAsync()` did exist on `IDriver`, but nothing ever polled the footprint on a 30 s cadence in v3, no driver ever declared a cache budget in `DriverConfig`, and the recycle machinery their only consumer belonged to was deleted in Gitea #522. Both members were removed from `IDriver` in Gitea #525. Retained here as the design intent a future process-wide watchdog could revive — not as a description of the system.
- **Per-instance allocation tracking**: drivers expose a `GetMemoryFootprint()` capability returning bytes attributable to their own caches (symbol cache, subscription items, queued operations). The Core polls this every 30 s and logs growth slope per driver instance.
- **Soft-limit on cached state**: each driver declares a memory budget for its caches in `DriverConfig`. On breach, the Core asks the driver to flush optional caches (e.g. discard symbol cache, force re-discovery). No process action.
- **Escalation rule**: if a driver instance's footprint cannot be bounded by cache flushing — or if growth is in opaque allocations the driver can't account for — that driver is a candidate for **promotion to Tier C**. Process recycle is the only safe leak remediation, and the only way to apply process recycle to a single driver is to give it its own process.
@@ -64,26 +64,5 @@ public interface IDriver
/// has a running / not-running distinction worth reporting.</para></summary>
/// <returns>The driver's current health snapshot.</returns>
DriverHealth GetHealth();
/// <summary>
/// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables).
/// <para>⚠️ <b>Nothing calls this (Gitea #525).</b> Its only consumer was <c>MemoryTracking</c>,
/// deleted with the rest of the driver-tier recycle machinery in Gitea #522 — the doc-comment here
/// used to claim Core polled it every 30 s, which was never true in v3. All 12 drivers still
/// implement it; most return a real number, and returning <c>0</c> is equally correct today. Do not
/// read a value from it and act on it without first building a consumer that is actually wired.</para>
/// </summary>
/// <returns>The approximate memory footprint in bytes.</returns>
long GetMemoryFootprint();
/// <summary>
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
/// Required-for-correctness state must NOT be flushed.
/// <para>⚠️ <b>Nothing calls this (Gitea #525)</b> — same reason as
/// <see cref="GetMemoryFootprint"/>: the memory-pressure path that would have invoked it is
/// gone.</para>
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}
@@ -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 />
@@ -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 ----
@@ -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;
@@ -2238,10 +2238,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)
@@ -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>
@@ -45,12 +45,6 @@ public sealed class DriverHostTests
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
@@ -248,13 +242,6 @@ public sealed class DriverHostTests
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
@@ -187,11 +187,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Discovers the address space by throwing an exception.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
@@ -224,11 +219,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="ct">Cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Discovers the address space and registers alarm conditions.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
@@ -573,18 +573,6 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
/// <summary>Verifies that FlushOptionalCachesAsync clears the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_clears_template_cache()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
}
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
@@ -210,9 +210,9 @@ public sealed class AbCipFetchUdtShapeTests
shape.ShouldBeNull();
}
/// <summary>Verifies that FlushOptionalCachesAsync empties the template cache.</summary>
/// <summary>Verifies that a cleared template cache is re-populated from the network.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
public async Task A_cleared_template_cache_refetches_from_the_controller()
{
var factory = new FakeTemplateReaderFactory
{
@@ -230,7 +230,10 @@ public sealed class AbCipFetchUdtShapeTests
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
// Gitea #525 deleted AbCipDriver.FlushOptionalCachesAsync, which is what used to clear this;
// the cache's own Clear() is now the only reset, and nothing in production calls it (see the
// AbCipTemplateCache remarks). What this test still pins is the cache-miss path below.
drv.TemplateCache.Clear();
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
@@ -174,9 +174,6 @@ public sealed class GalaxyDriverFactoryTests
await driver.ShutdownAsync(CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
driver.GetMemoryFootprint().ShouldBe(0);
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
@@ -10,8 +10,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate)
/// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate).
/// Driver-level infrastructure tests for finding Driver.Galaxy-007 (dispose-CTS gate).
/// Driver.Galaxy-011 (a non-zero GetMemoryFootprint estimate) was also covered here until
/// Gitea #525 deleted that member from IDriver as unconsumed surface.
/// </summary>
public sealed class GalaxyDriverInfrastructureTests
{
@@ -22,47 +23,11 @@ public sealed class GalaxyDriverInfrastructureTests
new GalaxyReconnectOptions());
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
/// <summary>Verifies that memory footprint is zero when there are no subscriptions.</summary>
[Fact]
public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
driver.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that memory footprint is nonzero after subscriptions are active.</summary>
[Fact]
public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
{
// When subscriptions are active the footprint estimate must be > 0 so the
// server's memory-pressure mechanism sees the Galaxy driver as a participant.
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
}
/// <summary>Verifies that memory footprint decreases after unsubscribing.</summary>
[Fact]
public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
var handle = await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
var afterSubscribe = driver.GetMemoryFootprint();
afterSubscribe.ShouldBeGreaterThan(0L);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsubscribe = driver.GetMemoryFootprint();
afterUnsubscribe.ShouldBeLessThan(afterSubscribe,
"footprint must decrease when subscriptions are removed");
}
//
// Three tests lived here asserting the footprint tracked the subscription registry. The member
// was deleted from IDriver in Gitea #525 as unconsumed surface — nothing ever read the number —
// and they went with it. The registry sizes they leaned on are still covered directly by the
// subscribe/unsubscribe tests below.
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
@@ -585,17 +585,21 @@ public sealed class MTConnectDiscoverTests
}
/// <summary>
/// …and after <see cref="MTConnectDriver.FlushOptionalCachesAsync"/> has dropped that cache,
/// discovery re-fetches and still streams the full tree. This is the whole reason discovery
/// must go through <c>GetOrFetchProbeModelAsync</c> rather than reading the cached field: a
/// memory-budget flush must never leave browse permanently answering "this agent has no data
/// items".
/// …and after the cache has been dropped, discovery re-fetches and still streams the full
/// tree. This is the whole reason discovery must go through <c>GetOrFetchProbeModelAsync</c>
/// rather than reading the cached field: a drop must never leave browse permanently
/// answering "this agent has no data items".
/// <para>Drives <see cref="MTConnectDriver.InvalidateProbeModel"/> — the Agent-restart drop,
/// which is the only thing that empties this cache in production. It used to drive
/// <c>FlushOptionalCachesAsync</c>, deleted with the rest of the unconsumed memory-pressure
/// surface in Gitea #525; that path had no production caller, so this now tests a case that
/// actually occurs.</para>
/// </summary>
[Fact]
public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
public async Task Discover_refetches_the_probe_model_after_the_cache_is_dropped()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
var cap = await DiscoverAsync(driver);
@@ -689,7 +693,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
client.ProbeFailure = new HttpRequestException("agent down");
var builder = new CapturingBuilder();
@@ -705,7 +709,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.InvalidateProbeModel();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
@@ -590,31 +590,21 @@ public sealed class MTConnectDriverLifecycleTests
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
// ---- footprint + flush ----
// ---- dropping the probe cache ----
//
// These used to drive FlushOptionalCachesAsync. Gitea #525 deleted it as an unconsumed IDriver
// member, along with GetMemoryFootprint and the footprint test that only existed to assert on
// it. What survives is InvalidateProbeModel — the Agent-restart drop, which is the only thing
// that empties this cache in production, and which has the same retired-session guard and the
// same "browse must not be left permanently empty" obligation.
[Fact]
public async Task Memory_footprint_grows_when_initialize_populates_the_caches()
public void Dropping_the_probe_cache_keeps_the_observation_index()
{
var (driver, _) = NewDriver();
var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
var before = driver.GetMemoryFootprint();
await driver.InitializeAsync("{}", CancellationToken.None);
var after = driver.GetMemoryFootprint();
driver.InvalidateProbeModel();
// Before init the only config-attributable state is the two authored tags.
before.ShouldBeLessThan(after);
after.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
{
var (driver, _) = await InitializedDriverAsync();
var before = driver.GetMemoryFootprint();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeLessThan(before);
driver.CachedProbeModel.ShouldBeNull();
// The index is required for correctness (it IS the read surface) — never flushable.
@@ -624,12 +614,12 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
public async Task Dropping_the_cache_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
{
// Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
// cache-budget breach would permanently disable browse on that driver instance.
// Task 12's DiscoverAsync reads the probe model. If dropping it left no way back, an Agent
// restart would permanently disable browse on that driver instance.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
client.ProbeCallCount.ShouldBe(1);
@@ -679,7 +669,7 @@ public sealed class MTConnectDriverLifecycleTests
// it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
// the caller already handles "not connected" and should have exactly one failure mode.
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
// Held locally: the gate is one-shot, so the client has already cleared its own reference by
// the time the request is parked on it.
@@ -712,7 +702,7 @@ public sealed class MTConnectDriverLifecycleTests
});
await driver.InitializeAsync("{}", CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
var client = built[0];
var staleModel = client.Probe;
@@ -742,22 +732,22 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
public async Task Dropping_the_cache_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
{
// Core polls the footprint and asks for a flush on its own schedule, so a flush arriving
// after the driver was torn down is ordinary, not exotic. It must observe the retired
// session and do nothing — publishing a "flushed" copy of it would reinstate a session
// holding an already-disposed client for every later caller.
// The /sample pump can see an Agent restart at any moment, so a drop arriving after the
// driver was torn down is ordinary, not exotic. It must observe the retired session and do
// nothing — publishing a "dropped" copy of it would reinstate a session holding an
// already-disposed client for every later caller.
//
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has
// no await point, so nothing can be interleaved between its read and its publish from a
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. The drop
// has no await point, so nothing can be interleaved between its read and its publish from a
// single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
// lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
// fetch-side CAS, which does have an await point, is covered by the test above.
var (driver, client) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
@@ -440,8 +440,8 @@ public sealed class MTConnectHostAndRediscoverTests
/// <summary>
/// <b>The probe is a liveness check, never a cache writer.</b> Publishing its answer into
/// <c>AgentSession.ProbeModel</c> would put a second writer on the field the lifecycle owns
/// under a compare-and-swap, and would silently re-warm a cache that
/// <c>FlushOptionalCachesAsync</c> (or an Agent restart) deliberately dropped.
/// under a compare-and-swap, and would silently re-warm a cache that an Agent restart
/// deliberately dropped.
/// </summary>
[Fact]
public async Task Probe_ticks_never_publish_into_the_session_probe_cache()
@@ -457,10 +457,9 @@ public sealed class MTConnectHostAndRediscoverTests
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeSameAs(original);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0);
// …and a flushed cache stays flushed until a real consumer asks for it.
await driver.FlushOptionalCachesAsync(Ct);
// …and a dropped cache stays dropped until a real consumer asks for it.
driver.InvalidateProbeModel();
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeNull();
}
@@ -159,24 +159,6 @@ public sealed class MqttDriverDiscoveryTests
results.ShouldBeEmpty();
}
/// <summary>
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
/// </summary>
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
@@ -334,14 +316,6 @@ public sealed class MqttDriverDiscoveryTests
fired.ShouldBe(0);
}
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
@@ -13,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <item>Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError</item>
/// <item>Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16</item>
/// <item>Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning</item>
/// <item>Driver.OpcUaClient-013 — GetMemoryFootprint and FlushOptionalCachesAsync contract</item>
/// <item>Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion</item>
/// </list>
/// </summary>
@@ -97,39 +96,11 @@ public sealed class OpcUaClientMediumFindingsRegressionTests
}
// ---- Driver.OpcUaClient-013 ----
/// <summary>Verifies that GetMemoryFootprint returns zero before discovery.</summary>
[Fact]
public void GetMemoryFootprint_returns_zero_before_first_discovery()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-mem-013");
// Pre-init: no nodes discovered yet, so footprint is 0.
drv.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that FlushOptionalCachesAsync completes without throwing.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_completes_without_throwing()
{
// FlushOptionalCachesAsync is a cheap no-op before any discovery. Must not throw.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush-013");
await Should.NotThrowAsync(async () =>
await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken));
}
/// <summary>Verifies that FlushOptionalCachesAsync resets the footprint counter.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_resets_footprint_counter()
{
// GetMemoryFootprint uses _discoveredNodeCount * 512. FlushOptionalCachesAsync resets
// _discoveredNodeCount to 0, so a subsequent call to GetMemoryFootprint returns 0.
// We drive this through a complete Discovery pass against a stub — instead, test the
// contract via FlushOptionalCachesAsync clearing a counter we can observe.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush2-013");
drv.GetMemoryFootprint().ShouldBe(0L, "pre-flush should be 0 (no discovery)");
await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
drv.GetMemoryFootprint().ShouldBe(0L, "post-flush should still be 0");
}
//
// Three tests lived here covering GetMemoryFootprint + FlushOptionalCachesAsync. Both members
// were deleted from IDriver in Gitea #525 as unconsumed surface, and these went with them. Worth
// noting one was vacuous anyway: it asserted a footprint of 0 before the flush and 0 after, with
// no discovery in between, so it would have passed against a flush that did nothing at all.
// ---- Driver.OpcUaClient-015: pure-logic paths ----
@@ -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;
}
@@ -266,10 +266,6 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
@@ -395,8 +395,6 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -335,9 +335,5 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -426,10 +426,6 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -289,10 +289,5 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -314,10 +314,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>A driver that implements <see cref="ISubscribable"/> + <see cref="IAlarmSource"/> and lets the
@@ -241,10 +241,5 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -129,8 +129,6 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
@@ -205,10 +205,6 @@ public sealed class DriverInstanceActorWriteAndSubscribeTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Writes the specified requests (never reached — actor fast-fails in Connecting).</summary>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -63,11 +63,6 @@ internal class StubDriver : IDriver
}
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>